Skip to content

Layers

Everything under the client, for a program that wants one piece of it. ARCHITECTURE.md in the repository explains how they fit together; this is the reference for reaching them directly.

Sessions

sunnygram.storage.base.Storage

Somewhere a session can be kept.

Subclasses decide what that means: memory, a file, a string you carry around. All three are interchangeable, which lets a program be written once and moved between them.

open async

open() -> None

Get ready to be read from. Called once, before load.

load async

load() -> SessionState

Read the session back, or a fresh one if nothing was ever saved.

save async

save(state: SessionState) -> None

Write the session down, replacing whatever was there.

delete async

delete() -> None

Forget the session entirely. Logging out ends here.

close async

close() -> None

Let go of whatever open handle this backend holds.

sunnygram.storage.base.SessionState dataclass

Everything about a session that has to survive a restart.

The keys are per datacenter because each one issues its own, and a client that has been to more than one datacenter for files or a migration holds several. dc_id names the one that is home: the one that answers for the account.

authorized property

authorized: bool

Whether this session has been through a login.

auth_key

auth_key(dc_id: int | None = None) -> bytes | None

The key for a datacenter, or None if there has never been one.

set_auth_key

set_auth_key(dc_id: int, key: bytes | None) -> None

Record or forget the key for a datacenter.

sunnygram.storage.sqlite.SQLiteStorage

Bases: Storage, PeerStore

A session kept in a file on disk.

path property

path: Path

Where the session file is.

sunnygram.storage.string.StringStorage

Bases: MemoryStorage

A session that came from a string, and can go back to being one.

Built on the memory backend because that is what it is: nothing is written anywhere until someone asks for the string and puts it somewhere.

export

export() -> str

The string for the session as it stands.

Sync, because there is nothing to wait for, and because the natural moment to call it is right after a login, printing the result once.

sunnygram.storage.memory.MemoryStorage

Bases: Storage, PeerStore

A session kept in a variable.

sunnygram.storage.string.encode_session

encode_session(state: SessionState) -> str

Spell a session as a string.

Only the key for the home datacenter travels. Keys for the others are worth keeping in a file, where re-negotiating one costs nothing, but not worth tripling the length of something a person has to paste.

sunnygram.storage.string.decode_session

decode_session(text: str) -> SessionState

Read a session back from a string, refusing anything that is not one.

Logging in

sunnygram.auth.login.log_in async

log_in(invoker: Invoker, *, phone_number: str | Callable[[], Any], code: Callable[[SentCode], Any], password: Callable[[str], Any] | None = None, bot_token: str | None = None) -> types.User

Sign in, asking for whatever is missing along the way.

The callables are how this stays usable from a script, a prompt or a chat window without knowing which it is: each is called when the answer is needed and may be sync or async. password is handed the account's hint, which is often the only reminder the person has.

A session that is already signed in is returned as it is, so this is safe to call every run.

sunnygram.auth.login.send_code async

send_code(invoker: Invoker, phone_number: str, *, settings: CodeSettings | None = None) -> SentCode

Ask Telegram to send a login code to a phone number.

The number may belong to another datacenter than the one we are talking to, in which case the server says so and the invoker moves before this returns.

sunnygram.auth.login.sign_in async

sign_in(invoker: Invoker, sent: SentCode, code: str) -> types.User

Finish a phone login with the code that arrived.

Raises SessionPasswordNeeded when the account has a second factor, which is not a failure: the code was right, and check_password is what comes next.

sunnygram.auth.login.check_password async

check_password(invoker: Invoker, password: str) -> types.User

Get past a second factor with the account password.

The password itself never leaves this machine. What goes out is a proof built from it, and building that proof is deliberately slow, so it happens off the event loop.

sunnygram.auth.login.sign_in_qr async

sign_in_qr(invoker: Invoker, show: Callable[[LoginToken], Any], *, timeout: float = QR_TIMEOUT, poll: float = QR_POLL) -> types.User

Log in by having an already-signed-in client scan a code.

show is called with each token, and again whenever one expires and is replaced, so whatever is drawing the code can redraw it. Raises SessionPasswordNeeded if the account has a second factor, exactly as a phone login does, and check_password finishes it the same way.

sunnygram.auth.login.sign_in_bot async

sign_in_bot(invoker: Invoker, token: str) -> types.User

Sign in as a bot, which needs nothing but its token.

sunnygram.auth.login.resend_code async

resend_code(invoker: Invoker, sent: SentCode) -> SentCode

Ask for the code again, usually by another route than the first.

sunnygram.auth.login.log_out async

log_out(invoker: Invoker) -> None

End this session and forget everything that was kept for it.

The key is dead on the server the moment this returns, so keeping our copy would only be a credential that no longer opens anything.

sunnygram.auth.login.get_me async

get_me(invoker: Invoker) -> types.User

Who this session is signed in as.

sunnygram.auth.login.SentCode dataclass

A code is on its way, and this is what signing in with it will need.

The hash is what ties the code to the request that asked for it, so it has to come back with the code. kind says where the code went, which is worth telling the person, since a code in the Telegram app and a code in an SMS look for different things.

sunnygram.auth.login.LoginToken dataclass

A QR login waiting to be scanned.

url property

url: str

The link to put in the QR code.

An official client that scans this is being asked to authorize us, so what it encodes is a credential in flight. Show it to the person logging in and no one else.

Peers

sunnygram.peers.resolver.resolve async

resolve(invoker: Invoker, target: Target) -> base.InputPeer

Name a peer to the server, however the caller happened to say it.

Costs nothing for anything the session has already seen. A username or a phone number that is genuinely new costs one call, after which it is known for good.

sunnygram.peers.resolver.resolve_username async

resolve_username(invoker: Invoker, username: str) -> PeerRecord

Ask the server who holds a username, and remember the answer.

Goes to the network even when the peer is already known, which is what makes it the way to notice that a name changed hands. resolve is the one to call for ordinary work.

sunnygram.peers.resolver.resolve_phone async

resolve_phone(invoker: Invoker, phone: str) -> PeerRecord

Ask the server whose number this is, and remember the answer.

Only works for numbers already in the account's contacts, which is Telegram being careful instead of this being incomplete.

sunnygram.peers.resolver.mark_id

mark_id(peer_id: int, kind: PeerKind) -> int

Spell an id the way the Bot API does, for talking to things that do.

Only useful at the edges: Sunnygram works in the ids the protocol uses.

sunnygram.peers.resolver.mark_peer

mark_peer(peer: Any) -> int | None

A Peer as the one number that names it, or nothing if it is not a peer.

The useful part is that the answer is a single int, so a peer fits anywhere something has to be written down: a dict key, a database column, a portable file reference. Handing it back to resolve reaches the same peer.

sunnygram.peers.resolver.unmark_id

unmark_id(marked: int) -> tuple[int, PeerKind]

Read a Bot API style id back, with the kind its sign implies.

The kind that comes out is the coarse one, because that is all a sign can carry: a person and a bot are spelled alike, and so are a channel and a supergroup. That is enough to build an input peer, and the cache knows better anyway whenever it has met the peer before.

Files

sunnygram.files.download.download_file async

download_file(invoker: Invoker, source: Any, *, into: str | PathLike[str] | None = None, chunk_size: int = DOWNLOAD_CHUNK, workers: int = WORKERS, progress: Progress | None = None, refresh: Refresh | None = None, limit: int = 0, cdn: bool = True) -> bytes | Path

Fetch a file, into memory or onto disk.

The source is a message, the media off one, a document, a photo, or a FileSource that locate has already worked out. into names a file to write to and makes the answer that path; leaving it out makes the answer the bytes themselves, which is what a thumbnail or a small document usually wants.

limit refuses anything bigger than it, before fetching instead of after, for a program that is downloading something it did not choose.

cdn says whether to accept being sent to a content delivery network for a popular file, which is faster nearly everywhere and is what every other client does. Turning it off keeps the whole transfer inside Telegram, at the price of the datacenter having to serve it itself.

sunnygram.files.upload.upload_file async

upload_file(invoker: Invoker, source: str | PathLike[str] | bytes | bytearray | BinaryIO, *, name: str | None = None, part_size: int = UPLOAD_PART, workers: int = WORKERS, progress: Progress | None = None) -> base.InputFile

Send a file up, and return the handle for attaching it to something.

The source is a path, the bytes themselves, or anything with a read method. The answer is only good for one send, and only for a while: hand it to the call that posts the message rather than keeping it.

name is what the file will be called on the other side, and defaults to the name on disk, or to the id when there is nothing to take it from.

sunnygram.files.cdn.CdnSession

One file, on the CDN it was redirected to.

Built from the redirect and shared by every worker on that file, which is what lets one of them learn a hash or fix a cold cache for all of them.

dc_id property

dc_id: int

The CDN datacenter this file is being taken from.

block property

block: int

How much of the file one published hash covers.

Read off the hashes themselves rather than assumed, since it is the server's choice, and taken as the largest of them, since the last block of a file is a short one. It is what a request gets rounded out to, because a block is the smallest thing that can be checked.

fetch async

fetch(offset: int, limit: int) -> bytes

One piece of the file, decrypted and checked.

The bytes handed back are exactly the ones asked for. Getting them may mean fetching a little more, when the range asked for starts or ends inside a block, since a block is what a hash covers. That only happens to a caller who chose pieces smaller than the blocks Telegram publishes for the file, which is no one by default.

sunnygram.files.ref.file_ref

file_ref(what: Any, *, thumb: str | None = None, origin: bool = True) -> str

The portable reference for whatever file the caller is holding.

Takes the same things locate does: a message, the media off one, a document or a photo. thumb names a particular rendition instead of the largest, which is how to write down a thumbnail on its own.

A message also says where it came from, and that is packed in so the reference can refresh its own token later. Pass origin=False to leave it out.

sunnygram.files.ref.decode_ref

decode_ref(text: str) -> FileRef

Read a reference back, or say why this is not one.

Everything about the string is checked before anything is believed: the length, the version, the checksum, and that each part is inside what was written down. A reference that has been truncated in a database column or had a character eaten by a URL fails here rather than becoming a request for some other file (rule S3).

sunnygram.files.ref.parse_ref

parse_ref(text: str) -> FileRef | None

The same, answering None instead of raising, for a caller that is guessing.

This is what makes a string usable anywhere a file is: send_media and download can be handed one without a program having to say which of the two kinds of string it is holding.

sunnygram.files.ref.FileRef dataclass

A file, named portably: everything needed to fetch or resend it.

input property

input: Any

The file as the protocol names it, for anything taking one.

media property

media: InputMedia

The file as something a send call can carry.

source property

source: FileSource

The file as something the download engine can fetch.

encode

encode() -> str

Pack this back into the string form.

Topics

sunnygram.methods.forum.iter_topic_pages async

iter_topic_pages(invoker: Invoker, peer: Target, *, query: str = '', limit: int = 100, batch: int = TOPIC_BATCH) -> AsyncIterator[Any]

The topics in a forum, a page at a time.

The cursor is three things again: the date and id of the last topic's most recent message, and the topic's own id. Pinned topics come first however the rest are ordered, which is the server's doing and not something a cursor can express, so a page is taken as it arrives.

sunnygram.methods.forum.topics_by_id async

topics_by_id(invoker: Invoker, peer: Target, ids: list[int]) -> Any

Particular topics, by id, with the messages that opened them.

sunnygram.methods.forum.create_topic async

create_topic(invoker: Invoker, peer: Target, title: str, *, icon_color: int | None = None, icon_emoji_id: int | None = None, send_as: Target | None = None) -> Any

Open a topic, and answer with the updates that made it.

The new topic's id is the id of the message this creates, which is the one thing worth knowing about how forums are built: there is no separate id space. icon_color is one of the six Telegram allows and is ignored when a custom emoji is given, since that replaces the icon instead of tinting it.

sunnygram.methods.forum.edit_topic async

edit_topic(invoker: Invoker, peer: Target, topic_id: int, *, title: str | None = None, icon_emoji_id: int | None = None, closed: bool | None = None, hidden: bool | None = None) -> Any

Change a topic, leaving out whatever is not being changed.

Closing one stops anybody but an administrator posting in it. Hiding one takes it off the list without deleting anything, and only the general topic can be hidden.

sunnygram.methods.forum.pin_topic async

pin_topic(invoker: Invoker, peer: Target, topic_id: int, *, pinned: bool = True) -> Any

Hold a topic at the top of the list, or let it go.

sunnygram.methods.forum.reorder_topics async

reorder_topics(invoker: Invoker, peer: Target, order: list[int], *, force: bool = False) -> Any

Put the pinned topics in a given order, first in the list first.

force says that topics left out of the list are to be unpinned, rather than left pinned in whatever order they were.

sunnygram.methods.forum.delete_topic async

delete_topic(invoker: Invoker, peer: Target, topic_id: int, *, rounds: int = 100) -> int

Delete a topic and everything in it, and say how much went.

Telegram deletes a history a slice at a time and answers with how far it got, so this asks again until it says nothing is left. rounds is a ceiling on that, since a topic being written to as fast as it is deleted would otherwise never end.

sunnygram.methods.forum.toggle_forum async

toggle_forum(invoker: Invoker, peer: Target, enabled: bool, *, tabs: bool = False) -> Any

Turn topics on or off for a supergroup.

Telegram refuses this for a group with too few members, and turning it off does not delete the topics: everything that was in one moves back into the single conversation the group used to be.

sunnygram.methods.messages.reply_header

reply_header(reply_to: int | None, topic: int | None = None) -> base.InputReplyTo | None

Where in a chat a message belongs: a reply, a topic, or neither.

A forum topic is the message that opened it, and being in one is spelled as replying to that message, which is why one field says both things. Given only a topic, the message is posted to it; given both, the reply names the message being answered and the topic names the thread it is in.

Crypto

Written from scratch and validated against official vectors. Not something to call directly, and worth reading before trusting.

sunnygram.crypto.describe

describe() -> str

One line saying which ciphers are in use, for a diagnostic or a bug report.

Worth printing when a transfer is slower than it should be: the answer is almost always that this says python.

The wire

sunnygram.network.connection.Connection

One live, encrypted conversation with one datacenter.

Built around an already-connected transport and a session that holds the auth key for it, so what happens when the socket cannot be opened, and where the key came from, are both someone else's decision. See connect for the usual way to get one.

session property

session: Session

The session this connection speaks through.

dc_id property

dc_id: int

Which datacenter this connection reaches, if it was told.

in_flight property

in_flight: int

How many calls are waiting on an answer right now.

The one honest measure of how busy this connection is, which is what the pool above chooses on: a connection with nothing outstanding will answer sooner than one already carrying a file.

running property

running: bool

Whether the reader is alive and calls can still be made.

A recorded failure counts as not running even in the moment before the reader task has finished unwinding, so that nothing new is handed to a connection already known to be dead.

updates property

updates: Queue[TLObject]

Everything the server sent that was not an answer to a call.

The update layer drains this. It is bounded, because a consumer that stops draining must not be able to grow it without limit, and the reader never blocks on it: when it is full the newest update is dropped and counted, and a non-zero count is the update layer's cue that it has to catch up through getDifference instead of trust what it has.

dropped_updates property

dropped_updates: int

How many updates were dropped because no one was draining them.

unknown_constructors property

unknown_constructors: int

How many incoming objects no constructor in the pinned layer claimed.

Expected to be zero, and not fatal if it is not: a server running ahead of our schema can send something we cannot read, and dropping it is better than dropping the connection.

start async

start() -> None

Start the reader and the housekeeping that goes with it.

close async

close() -> None

Stop everything and end any call still waiting. Safe to repeat.

invoke async

invoke(request: TLFunction[TLResult], *, timeout: float | None = None) -> TLResult

Call a TL function and wait for what the server answers with.

The answer is typed as whatever the function says it is answered with, so invoking help.GetConfig gives back a Config and not an anything.

Raises whatever the server refused with, as a typed error. A FLOOD_WAIT short enough to sit out is waited out here instead, which is the safe default: the alternative is every caller writing the same retry.

sunnygram.session.session.Session

The state of one conversation with one datacenter.

time_offset property writable

time_offset: float

How far ahead of us the server's clock runs.

time_known property

time_known: bool

Whether this session has been told what time the server thinks it is.

adopt_server_time

adopt_server_time(server_msg_id: int) -> None

Set our clock from a message id the server itself minted.

Used when the server complains that our ids are too low or too high, and on the first message of a session that started from a stored key. Its own id says what time it thinks it is, which is the only opinion that matters, and adopting it lets the next id land inside the window.

reset

reset() -> None

Start again on the same key, under a new session id.

The remedy for a sequence number the server will not accept: there is no way to argue about the count, so the count is abandoned along with the session it belonged to. The key, the salt and the clock all survive, since none of them was what went wrong.

next_seq_no

next_seq_no(*, content_related: bool) -> int

The sequence number for the next message.

Content-related messages take an odd number and move the count on; everything else reads the count without touching it.

encrypt

encrypt(body: bytes, *, content_related: bool = True) -> tuple[int, bytes]

Wrap a serialized message, returning its id and the frame to send.

The id comes back because it is how an answer will be matched to this request.

encrypt_off_loop async

encrypt_off_loop(body: bytes, *, content_related: bool = True) -> tuple[int, bytes]

encrypt, with a big cipher call moved to a worker thread.

The id and the sequence number are still taken here, on the loop, and only the cipher travels. That matters: both of those are the order the server expects things in, so a caller has to hold whatever lock keeps sends in order across the await, exactly as it would for encrypt.

decrypt

decrypt(frame: bytes) -> Message

Unwrap a message and hold it to every incoming check.

decrypt_off_loop async

decrypt_off_loop(frame: bytes) -> Message

decrypt, with a big cipher call moved to a worker thread.

The checks stay here rather than travelling with the cipher, so the replay window is only ever touched by the task that owns this session.

receive

receive(frame: bytes) -> list[Message]

Unwrap a frame into the messages it really carries.

What arrives may be one message or a container of several, and each one inside a container has an id of its own that has to pass the same checks as the envelope's. A copy of something already handled is dropped rather than raised over, because a container is allowed to redeliver an answer we never acknowledged while still carrying messages that are new.

receive_off_loop async

receive_off_loop(frame: bytes) -> list[Message]

receive, with a big cipher call moved to a worker thread.

sunnygram.transport.tcp.TCPTransport

One TCP connection to one datacenter.

sunnygram.tl.core.TLObject

Base class for every TL constructor and function.

Subclasses set ID to their constructor id and implement the two halves of the codec. Defining a subclass with an id registers it, so a hand-written type is readable the moment its module is imported.

write

write(w: TLWriter) -> None

Append this object boxed: constructor id, then body.

write_body

write_body(w: TLWriter) -> None

Append this object's fields, with no constructor id.

read classmethod

read(r: TLReader) -> Self

Read this object's body. The constructor id is already consumed.