Skip to content

[Discussion:] ConversationHandler #2770

Open

Description

@Bibo-Joshi

Several ideas for changes and/or additions have been gathered in the v14 project, mentioned in the user group or issues or dwelled in the back of my head. So I'm writing this issue to keep track of them better.

The following list of ideas should be discussed before release of v14 because at least some of them are easier to implement if we do them non-backwards compatible. I'd like to emphasize that most of these are very rough first thoughts and I'm fully aware that we may scrape many of them.

Related: #2802


  • Make CH.fallbacks optional. I've seen a lot of people (including me) setting at to an empty list for some use cases … Poolitzer already expressed support for this idea
  • ConverstanionHandler + run_async: Problem is that we can't persist futures/promises. But we should be able to guarantee that all futures are done on (clean) shutdown. One could add something like BasePersistence.resolve_conversation_promises that persistence classes can call before flushing on shutdown. This already get's handled on v20
  • [FEATURE] ConversationHandler: Allow special handlers/callbacks for entering states #2390 (closed just for better overview, not actually implemented)
  • add a "prefallbacks" list, i.e. the state-handlers only trigger, if the "prefallback" handlers don't trigger. The current alternative is to add those handlers to the fallbacks list, which requires to make all state handlers ignore updates that should be handled by the fallbacks. see also Added impementation of "prefallbacks" list. #2764 #2764; Implemented "prefallbacks" feature #2960
  • Include entry_points, fallbacks (and eventual "prefallbacks") into the states dict. We already have special states for TIMEOUT and WAITING, so I think it would make the interface cleaner if we used special states (PRE)FALLBACK and END as keys for these lists rather than making them standalone kwargs of __init__
  • Think about "conversation related data", e.g. context.conversation_data. It should be an object unique per conversation key and ConversationHandler instance. It may even be automatically deleted when the conversation ends . See also [FEATURE] Have per-conversation data (or at least an identifier) in ConversationHandler #4136
  • Rethink which parts of ConversationHandler we consider public.
    • e.g. the conversations is currently public, although undocumented and basically part of the internals. If we make it private, we can rework the setters of persistence and conversations to something like get_persisted_data(persistence) or initialize_persistence(persistence)
    • We also rather regularly get questions like "how do I access the current state of the conversation" and "how do I check if a user has an active conversation". Making the logic of how the keys are build and how conversations works public may help with some of these and also with the above context.conversation_data
    • One could take this one step further and let the user customize how the key is generated. This could be useful e.g. in situations where one wants to handle 3rd-party updates (e.g. login into an external service) within the conversation, which are not of type telegram.Update. see also ConversationHandler do not support per_message_thread_id conversations #4768 for a request to make the CH state per message thread ID
  • think about conversation_timeout:
    • Is it worth to allow timeout handlers to put the conversation in a non-end state?
    • it it worth to try & allow a different timeout duration per state?
    • Can we make them work for nested conversations?
    • Can we ensure that the timeout jobs can be persisted?
  • [FEATURE] class-based nested conversations #2827 to make CH be based more on a class-like structure
  • nested conversations:
    • Check behavior of the WATING state, especially with nested conversations
    • Check behavior of async handlers with nested conversations
    • Once a child conversation has started one may want the next update to be processed exclusively by that child conversation, even if it's not the first handler in the state. Maybe this can be made possible. See also https://stackoverflow.com/questions/78143788
  • Think about a mechanism to lazy-load persisted conversation states to allow to reduce load on startup. would be especially useful for stateless server designs. See here for some initial discussion. Note also that in stateless setups, a refresh_conversation_data would be useful, see [QUESTION] How to work with persistent conversation data across multiple processes #4452.
  • When the conversation has ended, pass CH.END to persistence.update_conversation instead of None
  • We currently issue a warning for per_message=True, per_chat=False - this is not necessarily valid in case inline_message_ids are used
  • entry_points should be checked after the states are checked - that way, state handlers may trigger before any entry_points trigger via allow_reentry. See https://t.me/pythontelegrambotgroup/606782?thread=606781 for a question on that. Edit: On second thought, the allow_reentry may not be needed at all - the user can just repeat the entry_points in the fallbacks. If "prefallbacks" (see above) are added, that even allows the user to customize which of state handlers and entry_points are checked first
  • A commonly requested feature is to track a "conversation" between two (or more) users who each chat with the bot in the respective private chat. If we could make something like this feasible, that would be awesome.
  • discuss possibility to make job timeouts persistent: [FEATURE] Persist timeouts of ConversationHandler #3522
  • Improve type hinting where possible - see Sub-Optiomal type hints in ConversationHandler #3665 (closed only to track this here)
  • Errors in the timeout state should be handled like errors in handlers, not like errors in jobs - see https://t.me/pythontelegrambotgroup/675226
  • In case built-in states are used, they should either not be easy to reproduce or we should warn if the user tries to use them manually. See [FEATURE] warn if ConversationHandler.states has a key -1 #3834
  • Rethink if we want to allow for some error handling on CH or even state level. See From error state to other state on a Conversational bot #2277 for some discussion

Activity

  1. added this to the v14 milestone on Jan 3, 2022
  2. modified the milestones: v20, v20.0a1 on May 12, 2022
  3. modified the milestones: v20.0a1, v20.0 on May 21, 2022
  4. kirsanium commented on Jun 20, 2022

    @kirsanium

    I request for a tree-like behaviour on reentering nested ConversationHandlers - it seems reasonable to ConversationHandler.END all child handlers when you reenter the root one. Found no intended way to do it and my current solution is extremely hacky.

  5. Poolitzer commented on Aug 1, 2022

    @Poolitzer
    Member

    The grammy dev recently contacted me and pointed towards their implementation of conversation handlers. Since they are pure async as well, this might be interesting to consider. May be too much for either V20 or PTB in general and rather ptbcontrib, just wanted to write it down.

    https://t.me/grammyjs_news/36

  6. Bibo-Joshi commented on Aug 1, 2022

    @Bibo-Joshi
    MemberAuthor

    Interesting! Thanks for mentioning. A similar idea has come up in #3042 before. While my comments on that PR still hold, one can ofc still keep the concept in mind. Redesigning CH as a whole can probably come to many different results.

  7. modified the milestones: v20.0, v21.0 on Nov 5, 2022
  8. Bibo-Joshi commented on Jan 6, 2023

    @Bibo-Joshi
    MemberAuthor

    The grammy dev recently contacted me and pointed towards their implementation of conversation handlers. Since they are pure async as well, this might be interesting to consider. May be too much for either V20 or PTB in general and rather ptbcontrib, just wanted to write it down.

    https://t.me/grammyjs_news/36

    I've read through the grammy docs a bit and have a few comments on what I learned:

    Most important IMO is the observation, that the async conversation-function is not just simply executed. Instead on every await conversation.wait(), the function is basically ended and on the next incoming update the function is re-run up to that line. This is e.g. important for persistency. This mechanism makes it necessary to

    TL;DR: While this approach allows you to write easy-to-read code in a straightforward way at first glance, it's not quite so easy on second glance and the implementation is everything but easy.

    A second thing that I want to point out is the usage of conversation.wait/waitFor(…) and the usage of filter quries in waitFor. As far as I see by quickly skimming through the grammy docs, these filter queries are grammys analogue of BaseHandler.check_update (while in PTB filters are used exclusively for Message-handlers). With this background, wait(For) is a vary natural way to specify what update to wait for in the conversation. In PTB, the background is different and a similar approach would probably mean to eather abandon the BaseHandler setup or duplicate it into a filter-like setup.


    Grammys approach definitely has several perks, surely works well in grammys ecosystem and I have huge respect for whoever built it. But my summary is that grammys approach is not systematically superior to a classic FSM.

  9. KnorpelSenf commented on Jan 6, 2023

    @KnorpelSenf
    Contributor

    Glad to see that these ideas are spreading! It took a fair bit of research and many iterations and brainstorming to come up with the concept. And yes, it was a bit challenging to build, too :)

    In case you're going to consider something similar for PTB, note that you have control over the event loop in Python (in contrast to JS), which greatly simplifies the way how conversation builder functions are run. You can simply stop running the loop, while grammY conversations sort of have to implicitly intercept its execution via a special promise and then hack ourselves back into control.

    On the question why I personally dislike FSMs and similar abstractions, there's a good high-level summary of the ambition behind conversations in this interview starting at 26:34 min: https://podrocket.logrocket.com/grammy

    Looking forward to seeing what you'll come up with! Good luck!

  10. Bibo-Joshi commented on Jan 12, 2023

    @Bibo-Joshi
    MemberAuthor

    @KnorpelSenf thanks for your comment! I did a bit more reading and thinking on the topic, but haven't made any significant progress so far :D TBH I also don't quite understand your comment on controlling (and stopping) the event loop in Python. If I stop the event loop, then how will I process new updates?
    If you're interested you can also contact me on Telegram. I'd be happy to have a more in-depth discussion in private chat :)

  11. Bibo-Joshi commented on Jan 22, 2023

    @Bibo-Joshi
    MemberAuthor

    A few insights into the grammyjs conversation setup/how something like this could look like in python from offline discussion with KnorpelSenf:

    • If you use var = await conversation.external(some_function_that_must_not_be_replayed(…)), the return value of some_function_that_must_not_be_replayed must be persisted for the replay-mechanism to work properly. This can be an issue with general objects, e.g. sychronization primitives like locks are generally not serializable.
    • Object-identity can become a problem: If a variable leaves the scope of the conversation and the conversation is replayed, the variables inside the conversation and outside the conversation are no longer the same object. MWE in grammyjs, problem sketch in pseudo-python-code. This problem can probably be alliviated if the data is kept in memory as long as the bot is running, though there may be some edge cases where it stil appears
    • In Python, the necessity to call await conversation.external(some_coroutine_function(…)) can be removed by implementing a custom event loop. I.e. await some_coroutine_function(…) automatically takes care of storing the return value and the replay-logic. See here for a reference. However, this still only works for coroutine functions and for other functions that must only be run once (e.g. print(…), random), one would still need something like conversation.external
  12. rejedai commented on Apr 3, 2023

    @rejedai

    I apologize for the possible duplication, I did not find this in the branch.

    I think it is necessary to add a simple way to get the conversation by name and get the current state from the conversation.
    Example:

    main_conversation = context.application.handlers.get_by_name("main_conversation")
    state = main_conversation.get_state(CHAT_ID, USER_ID)

    Maybe I have bad notification design, but I don't need to send a notification when a person has passed a certain state. I used to bypass this with the help of a persist, I took data from the database, now when the persist is not realtime, I have to bypass it with the help of cyclic checking of handlers, which I think is not correct.

    my current user state code:

    async def get_state(context: CallbackContext, telegram_id: int):
        conversations = None
        user_state = None
    
        for handlers in context.application.handlers.values():
            for handler in handlers:
                if isinstance(handler, ConversationHandler) and handler.name:
                    # noinspection PyProtectedMember
                    conversations = handler._conversations
    
        if conversations is not None:
            for key, state in conversations.items():
                if telegram_id in key:
                    user_state = state
        return user_state
  13. 4 remaining items

  14. Poolitzer commented on Mar 9, 2024

    @Poolitzer
    Member

    Recently the idea came up in chat to allow custom classes in CH, because the subclass restriction of Update potentially forces one to input a lot of dummy values in the class.

    So if we ask them to e.g. subclass ConversationHandlerRequirement which only has user and/or chat id as attribute, we can decrease that dummy data amount

  15. Bibo-Joshi commented on Mar 9, 2024

    @Bibo-Joshi
    MemberAuthor

    @Poolitzer see also the 3rd bullet point in

    Rethink which parts of ConversationHandler we consider public.

  16. Bibo-Joshi commented on May 27, 2024

    @Bibo-Joshi
    MemberAuthor

    I've been brooding on an idea for a few weeks now and I don't think that I'll be able to publish a
    polished draft. So here's what I've got so far, including several open questions and discussion
    points.

    Abstract

    We introce an absract base class FSMManager that's used as Application.fsm_manager. This classes
    job is it to determine which handlers should be available for a given update by maintaining internal
    state information. Functionality to access and set the states is exposed via this class and
    corresponding shortcuts in the CallbackContext class.

    Motivation

    Introducing stateful logic effectively makes the complete Application stateful. Encapsuling the
    state-handling inside a single handler that has no direct interaction with Application leads to an
    unnatural cut. Granting explicit read and write access to states has been a frequently requested
    feature. Making this functionality accessible is not a technical problem if the corresponding
    interfaces cover eventual internal concurrency protection. Proper design and documentation can
    minimize the risk of users running into concurrency problems by accident.

    Implementation draft

    Here is some pseudo-code to give you an idea of how this could look like in terms of interfaces.

    New FSM specific classes

    class FSMKey(ABC):
        """Alternatively, we could simply use :class:`collections.Hashable`
        """
    
        @abstractmethod
        def __hash__(self) -> int:
            ...
    
        @abstractmethod
        @classmethod
        def from_update(cls, update: object) -> FSMKey:
            """Returns the FSM key for the update.
            If more than one key can be build for the update, the implementation may either chose one or raise an exception.
            If the update is not supported, the implementation may return NotImplemented in which case only handlers for the state FSMState.IDLE will be used.
            """
            # Not sure if we need such a method
    
    
    class ExampleFSMKey(FSMKey):
        chat_id: int | None
        user_id: int | None
    
        def __hash__(self) -> int:
            return hash((self.chat_id, self.user_id))
    
        @classmethod
        def from_update(cls, update: object) -> FSMKey:
            if not isinstance(update, Update):
                return NotImplemented
            return cls(chat_id=update.effective_chat.id, user_id=update.effective_user.id)
    
    
    class FSMState(ABC):
        """
        Represents a state, independent of the key.
        Implements bitwise and, or, not, and xor to allow logical combination of states without having to define too many state objects.
        Needs to ensure uniqueness of the uid via a class singleton.
        UID probably not strictly necessary for FSM implementation itself, but should be useful for logging, for easy identification of bitwise combined states and also for custom persistence implementations.
        """
    
        def __init__(self, uid: str) -> None:
            self.__uid: str = uid
    
        @property
        def uid(self) -> str:
            """UID including bitwise combinations"""
    
        def matches(self, fsm_state: FSMState) -> bool:
            """Checks if the passed state is covered by this state.
            For non-combined states, this just returnes ``self is fsm_state``.
            """
            # `|` and `^` check if one of the components matches
            # `~` checks if the state does *not* match
            # `&` acts like a non-combined state - this is mainly useful to define substates like ICE = WATER & FROZEN without having to manually defining a new object for ICE
    
    
    class FSMManager(ABC):
        # Probably a Generic class dependent on the FSMKey type
    
        @abstractmethod
        async def get_active_state(self, update: object) -> FSMState:
            """Returns exactly one active state for the update.
    	If more than one stored key applies to the update, one must chosen.
    	It's recommended to select the most specific one.
    
    	Example:
    	    The state of a chat, a user or a user in a specific chat could be tracked.
    	    For a message in that chat, the state of the user in that chat should be returned if available.
                Otherwise, the state of the chat should be returned.
            """
            # Example logic for ExampleFSMKey:
            if (
            global_state := self.states.get(ExampleFSMKey(None, None), FSMState.IDLE)) != FSMState.IDLE:
                return global_state
            if (chat_state := self.states.get(ExampleFSMKey(update.effective_chat.id, None),
                                              FSMState.IDLE)) != FSMState.IDLE:
                return chat_state
            if (user_state := self.states.get(ExampleFSMKey(None, update.effective_user.id),
                                              FSMState.IDLE)) != FSMState.IDLE:
                return chat_state
            return self.states.get(ExampleFSMKey(update.effective_chat.id, update.effective_user.id),
                                   FSMState.IDLE))
    
            @astractmethod
            async
    
            def do_set_state(self, key: FSMKey, state: FSMState) -> None:
                """Store the state for the specified key.
                If the state is ``FSMState.IDLE``, it's recommended to drop the key from the in-memory data.
                
                Warning:
                    This method should not be directly accessed by user code.
                    It is intended for internal use in this class only.
                    Instead, use :meth:`set_state`
                """
    
            async def set_state(self, key: FSMKey, state: FSMState) -> None:
                """Store the state for the specified key."""
    
            async with self.get_semaphore(key=key):
                self.do_set_state(key=key, state=state)
    
            def get_semaphore(self, key: FSMKey) -> asyncio.Semphore:
                """Returns a semaphore that is unique for this key at runtime.
                It can be used to prevent concurrent access to resources associated to this key.
                """
                # uses weakref.WeakValueDict internally

    Adaptions of exitisting interfaces

    class CallbackContext
        """Extend existing interface with convenience shortcuts."""
        # how to set these easily?
        self.fsm_key = fsm_key
        self.fsm_state = fsm_state
    
        @property
        def fsm_semaphore(self) -> asyncio.Semaphore:
            return self.application.fsm_manager.get_semaphore(self.fsm_key)
    
        async def set_fsm_state(self, state: FSMState, key: FSMKey = self.fsm_key) -> None:
            self.application.fsm_manager.set_state(key=key, state=FSMState)
    
    
    class Application:
    
        def add_handler(self, handler: BaseHandler, fsm_state: FSMState = FSMState.IDLE) -> None:
            """By specifying a state, it will be availble only if the update corresponnds to that state.
            """
    
        # Alternatively, make `fsm_state` an argument of `BaseHandler` (similar to `block`)
    
        async def process_update(self, update: object) -> None:
            """Updated to consider FSM states."""
            current_fsm_state = self.fsm_manager.get_active_state(update)
            for handlers in self.handlers.values():
                for handler, fsm_state in self.handlers.items():
                    if not fsm_state.matches(current_fsm_state):
                        continue
                    # Note: for each handler group, the handlers can call `context.set_fsm_state`, overriding the
                    # previous setting. `current_fsm_state == context.fsm_state` will still stay the same for the
                    # update in all groups. May need discussion.
                    await handler.handle_update(update, context)

    Adressing known questions

    How can existing functionality be covered?

    • Timeouts: Simply use the JobQueue.run_once to schedule calling of set_next_state. Optionally
      we can provide a convenience method for that. Since these jobs will require only simple input,
      persistence of timeout jobs won't be a problem.
    • fallback handlers: handler groups can be used for that
    • nested conversations: No more actual nesting. Nested conversations would simply be a set of
      dedicated states. You can set_next_state to them from where ever you like to trigger a "nested"
      behavior
    • the current WAITING state
      • see below
    • per_message: No longer necessary as user can implement a custom FSMKey & FSMManager. In the
      example above, one could simply add update.effective_message.id as additional level.

    How does this solve existing problems and feature requests?

    • tracking of conversations across chats: This allows to activate/deactivate (sets of) handlers
      for (groups of) chats and users, making it possible to track conversations across chats.
    • persitence of jobs and moving the conversation to a custom state from the timeout: see above
    • pre-fallbacks: handler gorups still exist
    • on-entry handlers to trigger when entering states: Probably the cleanest solution is to
      make FSMManager track these. That way, FSMManager.set_state can automatically call them.
      • Intefarce choises:
        • one callback per state
        • one or multiple handlers per state
        • allowing to make the on-entry handlers non-blocking (i.e. FSMManager.set_state
          returns before the callbacks have finished)
    • conversation related data
      • An easy solution would still to off-load that to the user and tell them to use things
        like context.bot_data.setdefault(context.fsm_key, {}). Automatic initialization and
        de-initialization on conversation end could possibly be implemneted by using on-entry states
        for FSMState.IDLE
    • improvements of nested conversations: no more actual nesting requried. see also above. In
      particular, choosing if only the nested conversation is active or if "parent" states are still
      available can be selected as needed
    • Persistence: Lazy-loading & refreshing. This always needs some form of tracking which data has already been
      loaded. Best we can do is outsource the responsibility for that to the persistency implementation.
      If we send a proper "IDLE" state to the presistency and document that this means that the
      conversation for that key is now no longer tracked in-memory, that should be sane.
      • Persistence interface probably still needs to be extended/adjusted
    • error handling in conversations: errors are handled by the usual error handlers.
      • setting a new state is possible via set_next_state
      • checking from which state the exception was triggered may still not be trivial, see also
        state-history
    • state-history (what was the previous state?)
      • FSMManager.do_set_state can be implemented to keep track of that
      • Optionally, we can provide in-memory tracking in a built-in way
      • persistency-tie-in remains uncertain especially for a built-in solution
    • Handling if custom update types: Covered since they key is no longer tied strictly
      to telegram.Update

    What new problems does this approach bring?

    • Usage of concurrency functionalityy (block=False, concurrent updates) can lead to race
      conditions if the user isn't careful
      • FSMManager.get_semaphore provides easy way to access semaphore per key to protect
        crucial logic
      • Custom implementations of BaseUpdateProcessor can ensure sequential processing per key
        where necessary via the semaphores
      • possible utility functionality wike async with context.as_fsm_state(WAITING_STATE): to
        make custom concurrency handling more accessible.
    • Reduction of multiple loosely coupled fsm-parts into one big FSM might be challanging for users
  17. modified the milestones: v22.0, v23 on Mar 15, 2025
  18. github-actions commented on Oct 16, 2025

    @github-actions

    This issue has been automatically closed due to inactivity. Feel free to comment in order to reopen or ask again in our Telegram support group at https://t.me/pythontelegrambotgroup.

  19. KnorpelSenf commented on Oct 16, 2025

    @KnorpelSenf
    Contributor

    This reminds me: A few months ago, I completely rewrote both grammY conversations and the documentation for it from scratch. The concepts outlined above stayed fundamentally the same, but the implementation is very straightforward now, the explanation of it is much better, and I was able to add tons of new cool features (like waiting for several types of updates at the same time, and essentially concurrency within the same function etc) that weren't possible before.

    If you want to check it out again, now might be a good time. The repo is still at https://github.com/grammyjs/conversations

  20. removed this from the v23 milestone on May 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Fields

    Priority

    None yet

    Effort

    None yet

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions