Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pyrogram Rework #109

Open
2 tasks done
Danstiv opened this issue Nov 20, 2024 · 0 comments
Open
2 tasks done

Pyrogram Rework #109

Danstiv opened this issue Nov 20, 2024 · 0 comments
Labels
enhancement New feature or request

Comments

@Danstiv
Copy link

Danstiv commented Nov 20, 2024

Checklist

  • I believe the idea is awesome and would benefit the framework
  • I have searched in the issue tracker for similar requests, including closed ones

Description

Pyrogram Rework

This long read describes various ideas/features that can be implemented in Pyrogram, or at least considered for implementation.

TLDR

Let's do it like in aiogram!

Introduction

While working with Pyrogram, I often encountered some shortcomings or a complete lack of necessary functionality. However, since it was the first framework for working with bots that I learned, these issues didn't seem significant to me.

Then I came across the discord.py library for working with Discord bots.

The capabilities and ease of use of this library impressed me, and at some point, I began to think that some features could be borrowed from there.

Recently, I returned to the aiogram documentation and was pleasantly surprised by the changes.

The first time I delved into it was back in 2022 when the choice was between aiogram and Pyrogram, and aiogram's documentation at that time was terrible — mostly placeholders and rough drafts.

Then, I made the obvious choice in favor of Pyrogram and even invented some helper on top (I'll write about it later), which compensated for Pyrogram's shortcomings.

Now, aiogram has transformed for the better; the documentation, though somewhat superficial, is more or less complete and structured, and the library itself looks decent.

aiogram also has interesting concepts that could be tried in Pyrogram.

Therefore, I will describe my thoughts on various improvements to Pyrogram, and I hope someone will find it interesting.

Why is this here?

As many of you know, the original Pyrogram has been abandoned and hasn't been updated since May 2023, stopping at quite an old 158th layer of the scheme.

Therefore, there's no point in writing there, and this fork seemed to me the most popular and active, so I decided to build upon it.

Why is this here? At least write and show some code first

I already wrote code back in 2022, about 3000 lines, and I wasn't very satisfied with the result.

I'm talking about the same helper over Pyrogram, which somewhat resembles a small framework but, in my opinion, isn't quite one.

It looks like this (Caution! Bad code!).

There you find error handling, something similar to middleware, initial interface elements using inline keyboards, an integrated base on SQLAlchemy, and even a built-in mechanism for saving users to the database.

But firstly, none of it is documented, and secondly, it's written poorly and haphazardly, so I don't officially recommend anyone use this creation.

I don't want to write code the same way again, as I might realize a year and a half later that I took the wrong turn at the beginning and end up with another pile of useless software.

Thus, I changed my approach and decided to reach out to the community, as many smart people (provided there's interest, which I hope for) together can come up with more and better than one not-so-smart member of society.

Who needs this?

I need this, at the very least, as I foresee having to deal with several large Telegram bots and want to do so on a convenient and supported framework.

Yes, now in 2024, one could use the matured aiogram, but Pyrogram seems like a more promising library to me, mainly due to direct interaction through MTProto and the ability to work from a full-fledged TG account.

Dispatcher

I propose starting the modifications with the dispatcher as it is one of the key elements of the library, and the changes I suggest will primarily affect the dispatcher.

What are the issues?

Firstly, we have no error handling in place at all. We can't add error handlers for our handlers, which prevents us from conveniently informing the bot user that their request failed.

We don't have middlewares, which also prevent us from performing certain tasks that are common for backend developers.
I compare Telegram and the web because, in my opinion, working with HTTP requests and processing Telegram events is very similar, and it would be appropriate to apply some principles used in web development within a Telegram framework.
For example, it would be very convenient to initialize a database session in middleware and then commit after the successful completion of handlers or rollback after an exception.

Another noticeable problem is the limited options for grouping handlers.
Grouping is done using groups, which seem to exist but don't really. A group is just an integer: if it's 0, it's the standard group; if it's negative, execution happens earlier; if it's positive, execution happens later.
No global filters can be applied, it's not easy to spread groups across files, middlewares can't be added... Oh right, they can't be added because they don't exist.

Plus, it's unclear how to synchronize these group numbers in the project. Should one create an enum for this?..
And if we consider plugins that might use groups internally and accidentally overlap with the main code base.
Plugins, by the way, are an interesting idea, but in my view, this mechanism lacks flexibility and essentially represents a high-level builder, which might not be very convenient when developing a large bot.
But I might be wrong about plugins; feel free to correct me if I'm missing something.


Initially, the dispatcher needs refactoring. Interesting work has been done in #95, but for some reason, the author of the PR is not responding. I think we can take some parts from this PR.
As for the rest, I'm not entirely sure which path to take or whether to take it at all.

aiogram way

In the third version of aiogram, a roughly similar scheme for processing events emerged. Session -> Dispatcher (root router) -> updates observer outer middlewares -> updates handler filters -> updates observer inner Middlewares -> user handler or default handler -> event observer outer middlewares -> event observer filters -> event observer inner middlewares -> handler.

I'll explain in more detail, comparing this to Pyrogram.
Updates come from the session, as they do in Pyrogram, and similarly in aiogram.
In aiogram, the dispatcher reads updates and processes them as async tasks, whereas in Pyrogram, multiple dispatchers (workers) receive updates from a queue, and all processing from filtering to consumer code execution is done within the worker.

I believe both approaches are valid. A downside of the first approach is the potential uncontrolled growth of hanging tasks, which can freeze the entire async loop, while using a fixed number of workers, as in Pyrogram, results in more predictable application behavior.
However, the aiogram approach, also used in discord.py, provides more freedom, and in the future, we could consider using such an approach instead of workers or alongside them.
For now, it's not important. The key point is that in both systems, updates move from the session to the dispatcher, but then they diverge.

In aiogram, updates first pass through outer middlewares, which do not exist in Pyrogram.
Outer middlewares allow processing of events before they go through filters, enabling useful application components like error handlers or loading language contexts for multilingual bots.

After passing through outer middlewares, a low-level update enters the built-in update observer handler, where it transforms into a high-level object.
This high-level object is then reprocessed, not by the update observer but by the observer corresponding to the type of the high-level object. For example, it might be a message or callback_query.

In Pyrogram, this process is handled by the dispatcher, which determines the handler type based on the update and parses the low-level update into a high-level one.
In aiogram, the update then goes through the root router and its associated sub-routers until a handler for the update is found.
Such mechanisms do not exist in Pyrogram; for instance, plugins do not add any additional layer of abstraction, and their handlers end up in the same namespace.

Also, Pyrogram does not have observers; finding a handler every time involves calling isinstance on each registered handler.
Instead of routers, groups are used, and this is a key difference in event handling between aiogram and Pyrogram. The approach with groups, in my opinion, is not the most successful.
Firstly, a group is not an entity from the end programmer's perspective — we can only specify a relative value by which handlers will be grouped.
In other words, a group simultaneously sets the handler execution priority and isolates it from handlers in other groups.

The problem is, a group is not a unique entity technically — it's just a positive or negative number that sets a relative priority.
This approach can quickly lead to confusion when the number of groups increases. I faced such an issue when implementing helpers; I hadn't yet reached the idea of using middlewares and implemented various intermediary components using groups.
I ended up with about ten groups and had to create a separate manager to dynamically allocate numbers less than zero for initializing groups and numbers greater than zero for finalizing groups.

Additionally, Pyrogram handles stopping handler searching differently — in each group, a handler is called independently of the execution result of a handler from another group. In aiogram, the search stops with the first matching handler filter.
That is, if two handlers with identical filters exist in different routers, only the first one found is called, with no option to call the second.
Technically, based on the source code, we can throw a SkipHandler exception like in Pyrogram, but this method is undocumented, and officially there are no equivalents of stop propagation and continue propagation in aiogram.

Deciding what to do with Pyrogram shouldn't be solely up to me; it should be a community decision, And I will express my opinion
My stance is based on some experience developing bots and the similarity between handling Telegram events and web requests.
In web frameworks, there are no inner and outer middlewares, whereas aiogram has them. This decision was made because web requests are already standardized, with no notably different and complex structures, while they do exist in Telegram.

In the web, there might be GET or POST requests, which are conceptually opposite, but technically there's not much difference — both have a method, query, headers, and a body.
Yes, GET usually doesn't have a body, and it's technically forbidden by the specs, but for this analogy, it's not important.
For such requests, one type of middleware is sufficient.

In the web context, filters essentially consist of a combination of the method and the path, and what aiogram refers to as inner middleware might initially seem absent.
However, in FastAPI, for instance, there's dependency injection that allows attaching additional logic after the request handler is determined but before it's executed.
Thus, dependencies can be considered an analogue to aiogram's inner middleware.

This analogy isn't perfect, as aiogram's middlewares get called for all parent routers, due in part to the fact that it's not as straightforward to establish filtering in Telegram as it is for HTTP requests.
Importantly, because of the relatively simple and unambiguous filtering in handling HTTP requests, only one handler is typically called, which seems to suffice for most needs.

This analogy isn't entirely accurate, of course, since a web server may receive a request once and respond to it once, whereas in Telegram, an update and the response to it aren't explicitly linked; they are connected only by higher-level business logic.
Nonetheless, I believe that event processing should follow this model: we receive an update, find a handler, and stop there.

While I may certainly be wrong, I hope someone can provide use cases where multiple handlers should trigger for a single update.
Additionally, we likely should maintain backward compatibility, so we can't entirely discard existing capabilities.

Discord.py

In discord.py, handler isolation is implemented via Cogs (which I understand to be similar to command groups). Cogs in discord.py are a bit less flexible than routers in aiogram, as you cannot nest one cog inside another.
However, in many cases, this might suffice for effectively dividing a complex bot into separate components.

Updates in discord.py are processed similarly to Pyrogram in that the presence of one cog does not affect the invocation of listeners in other cogs. However, unlike Pyrogram, the order in which listeners are called in discord.py is not defined.
Also, technically, it's impossible to add multiple handlers for the same event within a single cog or bot, but this usually does not cause any inconvenience, the library is just designed that way, and it works great.

In general, discord.py has a slightly different filtering and event processing pipeline, but some concepts can be borrowed from there.
For instance, you can define handlers within a cog by decorating class methods — something you can't do in Pyrogram or aiogram.
aiogram does have class-based handlers, but they allow handling only one type of event per class, whereas a cog in discord.py can merge multiple different handlers, like one for commands and another for reactions, within a single class.

This approach can be convenient when handlers need access to a shared resource, but several variants of that resource exist within the application.
For example, one might combine handlers for interacting with OpenAI into a single cog, with the shared resource being an OpenAI API client.
In cases where a separate OpenAI client is required for individual servers or channels, a new cog instance could be created, housing its own isolated instance of the OpenAI client.

RPC Errors

There are two main issues with error handling:

Flood waits

When handling errors, flood waits are not synchronized for identical parallel calls.
For instance, if two tasks are sending messages and one receives a flood wait error, the other remains unaware and will continue attempting the call even though the flood wait period has not yet expired.
Addressing such collisions isn't straightforward, as sending messages to different chats can involve different waiting times, just like any other function.
It might be necessary to create a unique key for each call; for example, for sending a message, a key could be ("send_message", chat_id).
Still, there's no guarantee that this method will be entirely effective. However, this approach is certainly better than receiving a cascade of flood waits from multiple parallel tasks.
Moreover, spamming Telegram servers this way is likely to increase the delay for previously received flood waits.

Retrying failed invokes

There's currently no facility to retry the original call. When server errors occur, like a 500 error, or a disconnection happens, a high-level method call, such as client.send_message, may throw an exception.
Even if this exception is caught and the method is retried, under the hood, it creates a new low-level object with a different random ID.
If the original message for which the server responded with a 500 error or the connection was lost is eventually delivered, it will be sent again because, to Telegram, it's a completely new message.
One potential solution is to attach a retry method or something similar to the exception. When this method is invoked, the exact same function object that was originally constructed would be sent to the session.

Conclusion

Above, I've outlined just some of my thoughts on potential modifications and enhancements for Pyrogram. This write-up already turned out to be quite lengthy, so I'll stop here and see if it piques anyone's interest.

In the coming weeks, I will try to refactor the dispatcher if I find time outside of work.

Thank you for reading, and I look forward to your feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant