Repository: python-telegram-bot/python-telegram-bot

Issue:
Simple support for nesting ConversationHandlers
Allowed **ConversationHandler** to possibly return a value from its **handle_update** method

Useful for allowing **ConversationHandler** to be nested in another **ConversationHandler** (see #405).

This solution was inspired by [transitions](https://github.com/pytransitions/transitions) library's [implementation of Hierarchical State Machines (HSM)](https://github.com/pytransitions/transitions#reuse-of-previously-created-hsms) (see the 'remap' keyword).

With this change it's now possible to return arbitrary state names in the nested **ConversationHandler** as long as the state name is mapped to an external **ConversationHandler** state (e.g. you can have your callback functions return SUCCESS, FAILURE, CANCELED etc. as you wish. You can also map ConversationHandler.END on one or both ends).


Discussion:

Resolving patch:
diff --git a/examples/nestedconversationbot.py b/examples/nestedconversationbot.py
new file mode 100644
--- /dev/null
+++ b/examples/nestedconversationbot.py
@@ -0,0 +1,362 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# This program is dedicated to the public domain under the CC0 license.
+
+"""
+First, a few callback functions are defined. Then, those functions are passed to
+the Dispatcher and registered at their respective places.
+Then, the bot is started and runs until we press Ctrl-C on the command line.
+
+Usage:
+Example of a bot-user conversation using nested ConversationHandlers.
+Send /start to initiate the conversation.
+Press Ctrl-C on the command line or send a signal to the process to stop the
+bot.
+"""
+
+import logging
+
+from telegram import (InlineKeyboardMarkup, InlineKeyboardButton)
+from telegram.ext import (Updater, CommandHandler, MessageHandler, Filters,
+                          ConversationHandler, CallbackQueryHandler)
+
+# Enable logging
+logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+                    level=logging.INFO)
+
+logger = logging.getLogger(__name__)
+
+# State definitions for top level conversation
+SELECTING_ACTION, ADDING_MEMBER, ADDING_SELF, DESCRIBING_SELF = map(chr, range(4))
+# State definitions for second level conversation
+SELECTING_LEVEL, SELECTING_GENDER = map(chr, range(4, 6))
+# State definitions for descriptions conversation
+SELECTING_FEATURE, TYPING = map(chr, range(6, 8))
+# Meta states
+STOPPING, SHOWING = map(chr, range(8, 10))
+# Shortcut for ConversationHandler.END
+END = ConversationHandler.END
+
+# Different constants for this example
+(PARENTS, CHILDREN, SELF, GENDER, MALE, FEMALE, AGE, NAME, START_OVER, FEATURES,
+ CURRENT_FEATURE, CURRENT_LEVEL) = map(chr, range(10, 22))
+
+
+# Helper
+def _name_switcher(level):
+    if level == PARENTS:
+        return ('Father', 'Mother')
+    elif level == CHILDREN:
+        return ('Brother', 'Sister')
+
+
+# Top level conversation callbacks
+def start(update, context):
+    """Select an action: Adding parent/child or show data."""
+    text = 'You may add a familiy member, yourself show the gathered data or end the ' \
+           'conversation. To abort, simply type /stop.'
+    buttons = [[
+        InlineKeyboardButton(text='Add family member', callback_data=str(ADDING_MEMBER)),
+        InlineKeyboardButton(text='Add yourself', callback_data=str(ADDING_SELF))
+    ], [
+        InlineKeyboardButton(text='Show data', callback_data=str(SHOWING)),
+        InlineKeyboardButton(text='Done', callback_data=str(END))
+    ]]
+    keyboard = InlineKeyboardMarkup(buttons)
+
+    # If we're starting over we don't need do send a new message
+    if context.user_data.get(START_OVER):
+        update.callback_query.edit_message_text(text=text, reply_markup=keyboard)
+    else:
+        update.message.reply_text('Hi, I\'m FamiliyBot and here to help you gather information'
+                                  'about your family.')
+        update.message.reply_text(text=text, reply_markup=keyboard)
+
+    context.user_data[START_OVER] = False
+    return SELECTING_ACTION
+
+
+def adding_self(update, context):
+    """Add information about youself."""
+    context.user_data[CURRENT_LEVEL] = SELF
+    text = 'Okay, please tell me about yourself.'
+    button = InlineKeyboardButton(text='Add info', callback_data=str(MALE))
+    keyboard = InlineKeyboardMarkup.from_button(button)
+
+    update.callback_query.edit_message_text(text=text, reply_markup=keyboard)
+
+    return DESCRIBING_SELF
+
+
+def show_data(update, context):
+    """Pretty print gathered data."""
+    def prettyprint(user_data, level):
+        people = user_data.get(level)
+        if not people:
+            return '\nNo information yet.'
+
+        text = ''
+        if level == SELF:
+            for person in user_data[level]:
+                text += '\nName: {0}, Age: {1}'.format(person.get(NAME, '-'), person.get(AGE, '-'))
+        else:
+            male, female = _name_switcher(level)
+
+            for person in user_data[level]:
+                gender = female if person[GENDER] == FEMALE else male
+                text += '\n{0}: Name: {1}, Age: {2}'.format(gender, person.get(NAME, '-'),
+                                                            person.get(AGE, '-'))
+        return text
+
+    ud = context.user_data
+    text = 'Yourself:' + prettyprint(ud, SELF)
+    text += '\n\nParents:' + prettyprint(ud, PARENTS)
+    text += '\n\nChildren:' + prettyprint(ud, CHILDREN)
+
+    buttons = [[
+        InlineKeyboardButton(text='Back', callback_data=str(END))
+    ]]
+    keyboard = InlineKeyboardMarkup(buttons)
+
+    update.callback_query.edit_message_text(text=text, reply_markup=keyboard)
+    ud[START_OVER] = True
+
+    return SHOWING
+
+
+def stop(update, context):
+    """End Conversation by command."""
+    update.message.reply_text('Okay, bye.')
+
+    return END
+
+
+def end(update, context):
+    """End conversation from InlineKeyboardButton."""
+    text = 'See you around!'
+    update.callback_query.edit_message_text(text=text)
+
+    return END
+
+
+# Second level conversation callbacks
+def select_level(update, context):
+    """Choose to add a parent or a child."""
+    text = 'You may add a parent or a child. Also you can show the gathered data or go back.'
+    buttons = [[
+        InlineKeyboardButton(text='Add parent', callback_data=str(PARENTS)),
+        InlineKeyboardButton(text='Add child', callback_data=str(CHILDREN))
+    ], [
+        InlineKeyboardButton(text='Show data', callback_data=str(SHOWING)),
+        InlineKeyboardButton(text='Back', callback_data=str(END))
+    ]]
+    keyboard = InlineKeyboardMarkup(buttons)
+    update.callback_query.edit_message_text(text=text, reply_markup=keyboard)
+
+    return SELECTING_LEVEL
+
+
+def select_gender(update, context):
+    """Choose to add mother or father."""
+    level = update.callback_query.data
+    context.user_data[CURRENT_LEVEL] = level
+
+    text = 'Please choose, whom to add.'
+
+    male, female = _name_switcher(level)
+
+    buttons = [[
+        InlineKeyboardButton(text='Add ' + male, callback_data=str(MALE)),
+        InlineKeyboardButton(text='Add ' + female, callback_data=str(FEMALE))
+    ], [
+        InlineKeyboardButton(text='Show data', callback_data=str(SHOWING)),
+        InlineKeyboardButton(text='Back', callback_data=str(END))
+    ]]
+
+    keyboard = InlineKeyboardMarkup(buttons)
+    update.callback_query.edit_message_text(text=text, reply_markup=keyboard)
+
+    return SELECTING_GENDER
+
+
+def end_second_level(update, context):
+    """Return to top level conversation."""
+    context.user_data[START_OVER] = True
+    start(update, context)
+
+    return END
+
+
+# Third level callbacks
+def select_feature(update, context):
+    """Select a feature to update for the person."""
+    buttons = [[
+        InlineKeyboardButton(text='Name', callback_data=str(NAME)),
+        InlineKeyboardButton(text='Age', callback_data=str(AGE)),
+        InlineKeyboardButton(text='Done', callback_data=str(END)),
+    ]]
+    keyboard = InlineKeyboardMarkup(buttons)
+
+    # If we collect features for a new person, clear the cache and save the gender
+    if not context.user_data.get(START_OVER):
+        context.user_data[FEATURES] = {GENDER: update.callback_query.data}
+        text = 'Please select a feature to update.'
+        update.callback_query.edit_message_text(text=text, reply_markup=keyboard)
+    # But after we do that, we need to send a new message
+    else:
+        text = 'Got it! Please select a feature to update.'
+        update.message.reply_text(text=text, reply_markup=keyboard)
+
+    context.user_data[START_OVER] = False
+    return SELECTING_FEATURE
+
+
+def ask_for_input(update, context):
+    """Prompt user to input data for selected feature."""
+    context.user_data[CURRENT_FEATURE] = update.callback_query.data
+    text = 'Okay, tell me.'
+    update.callback_query.edit_message_text(text=text)
+
+    return TYPING
+
+
+def save_input(update, context):
+    """Save input for feature and return to feature selection."""
+    ud = context.user_data
+    ud[FEATURES][ud[CURRENT_FEATURE]] = update.message.text
+
+    ud[START_OVER] = True
+
+    return select_feature(update, context)
+
+
+def end_describing(update, context):
+    """End gathering of features and return to parent conversation."""
+    ud = context.user_data
+    level = ud[CURRENT_LEVEL]
+    if not ud.get(level):
+        ud[level] = []
+    ud[level].append(ud[FEATURES])
+
+    # Print upper level menu
+    if level == SELF:
+        ud[START_OVER] = True
+        start(update, context)
+    else:
+        select_level(update, context)
+
+    return END
+
+
+def stop_nested(update, context):
+    """Completely end conversation from within nested conversation."""
+    update.message.reply_text('Okay, bye.')
+
+    return STOPPING
+
+
+# Error handler
+def error(update, context):
+    """Log Errors caused by Updates."""
+    logger.warning('Update "%s" caused error "%s"', update, context.error)
+
+
+def main():
+    # Create the Updater and pass it your bot's token.
+    # Make sure to set use_context=True to use the new context based callbacks
+    # Post version 12 this will no longer be necessary
+    updater = Updater("TOKEN", use_context=True)
+
+    # Get the dispatcher to register handlers
+    dp = updater.dispatcher
+
+    # Set up third level ConversationHandler (collecting features)
+    description_conv = ConversationHandler(
+        entry_points=[CallbackQueryHandler(select_feature,
+                                           pattern='^' + str(MALE) + '$|^' + str(FEMALE) + '$')],
+
+        states={
+            SELECTING_FEATURE: [CallbackQueryHandler(ask_for_input,
+                                                     pattern='^(?!' + str(END) + ').*$')],
+            TYPING: [MessageHandler(Filters.text, save_input)],
+        },
+
+        fallbacks=[
+            CallbackQueryHandler(end_describing, pattern='^' + str(END) + '$'),
+            CommandHandler('stop', stop_nested)
+        ],
+
+        map_to_parent={
+            # Return to second level menu
+            END: SELECTING_LEVEL,
+            # End conversation alltogether
+            STOPPING: STOPPING,
+        }
+    )
+
+    # Set up second level ConversationHandler (adding a person)
+    add_member_conv = ConversationHandler(
+        entry_points=[CallbackQueryHandler(select_level,
+                                           pattern='^' + str(ADDING_MEMBER) + '$')],
+
+        states={
+            SELECTING_LEVEL: [CallbackQueryHandler(select_gender,
+                                                   pattern='^{0}$|^{1}$'.format(str(PARENTS),
+                                                                                str(CHILDREN)))],
+            SELECTING_GENDER: [description_conv]
+        },
+
+        fallbacks=[
+            CallbackQueryHandler(show_data, pattern='^' + str(SHOWING) + '$'),
+            CallbackQueryHandler(end_second_level, pattern='^' + str(END) + '$'),
+            CommandHandler('stop', stop_nested)
+        ],
+
+        map_to_parent={
+            # After showing data return to top level menu
+            SHOWING: SHOWING,
+            # Return to top level menu
+            END: SELECTING_ACTION,
+            # End conversation alltogether
+            STOPPING: END,
+        }
+    )
+
+    # Set up top level ConversationHandler (selecting action)
+    conv_handler = ConversationHandler(
+        entry_points=[CommandHandler('start', start)],
+
+        states={
+            SHOWING: [CallbackQueryHandler(start, pattern='^' + str(END) + '$')],
+            SELECTING_ACTION: [
+                add_member_conv,
+                CallbackQueryHandler(show_data, pattern='^' + str(SHOWING) + '$'),
+                CallbackQueryHandler(adding_self, pattern='^' + str(ADDING_SELF) + '$'),
+                CallbackQueryHandler(end, pattern='^' + str(END) + '$'),
+            ],
+            DESCRIBING_SELF: [description_conv],
+        },
+
+        fallbacks=[CommandHandler('stop', stop)],
+    )
+    # Because the states of the third level conversation map to the ones of the
+    # second level conversation, we need to be a bit hacky about that:
+    conv_handler.states[SELECTING_LEVEL] = conv_handler.states[SELECTING_ACTION]
+    conv_handler.states[STOPPING] = conv_handler.entry_points
+
+    dp.add_handler(conv_handler)
+
+    # log all errors
+    dp.add_error_handler(error)
+
+    # Start the Bot
+    updater.start_polling()
+
+    # Run the bot until you press Ctrl-C or the process receives SIGINT,
+    # SIGTERM or SIGABRT. This should be used most of the time, since
+    # start_polling() is non-blocking and will stop the bot gracefully.
+    updater.idle()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/telegram/ext/conversationhandler.py b/telegram/ext/conversationhandler.py
--- a/telegram/ext/conversationhandler.py
+++ b/telegram/ext/conversationhandler.py
@@ -69,6 +69,20 @@ class ConversationHandler(Handler):
     To end the conversation, the callback function must return :attr:`END` or ``-1``. To
     handle the conversation timeout, use handler :attr:`TIMEOUT` or ``-2``.
 
+    Note:
+        In each of the described collections of handlers, a handler may in turn be a
+        :class:`ConversationHandler`. In that case, the nested :class:`ConversationHandler` should
+        have the attribute :attr:`map_to_parent` which allows to return to the parent conversation
+        at specified states within the nested conversation.
+
+        Note that the keys in :attr:`map_to_parent` must not appear as keys in :attr:`states`
+        attribute or else the latter will be ignored. You may map :attr:`END` to one of the parents
+        states to continue the parent conversation after this has ended or even map a state to
+        :attr:`END` to end the *parent* conversation from within the nested one. For an example on
+        nested :class:`ConversationHandler` s, see our `examples`_.
+
+    .. _`examples`: https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples
+
     Attributes:
         entry_points (List[:class:`telegram.ext.Handler`]): A list of ``Handler`` objects that can
             trigger the start of the conversation.
@@ -93,6 +107,9 @@ class ConversationHandler(Handler):
             persistence
         persistent (:obj:`bool`): Optional. If the conversations dict for this handler should be
             saved. Name is required and persistence has to be set in :class:`telegram.ext.Updater`
+        map_to_parent (Dict[:obj:`object`, :obj:`object`]): Optional. A :obj:`dict` that can be
+            used to instruct a nested conversationhandler to transition into a mapped state on
+            its parent conversationhandler in place of a specified nested state.
 
     Args:
         entry_points (List[:class:`telegram.ext.Handler`]): A list of ``Handler`` objects that can
@@ -124,6 +141,9 @@ class ConversationHandler(Handler):
             persistence
         persistent (:obj:`bool`, optional): If the conversations dict for this handler should be
             saved. Name is required and persistence has to be set in :class:`telegram.ext.Updater`
+        map_to_parent (Dict[:obj:`object`, :obj:`object`], optional): A :obj:`dict` that can be
+            used to instruct a nested conversationhandler to transition into a mapped state on
+            its parent conversationhandler in place of a specified nested state.
 
     Raises:
         ValueError
@@ -147,7 +167,8 @@ def __init__(self,
                  per_message=False,
                  conversation_timeout=None,
                  name=None,
-                 persistent=False):
+                 persistent=False,
+                 map_to_parent=None):
 
         self.entry_points = entry_points
         self.states = states
@@ -165,6 +186,7 @@ def __init__(self,
         self.persistence = None
         """:obj:`telegram.ext.BasePersistance`: The persistence used to store conversations.
         Set by dispatcher"""
+        self.map_to_parent = map_to_parent
 
         self.timeout_jobs = dict()
         self.conversations = dict()
@@ -333,7 +355,11 @@ def handle_update(self, update, dispatcher, check_result, context=None):
                 self._trigger_timeout, self.conversation_timeout,
                 context=_ConversationTimeoutContext(conversation_key, update, dispatcher))
 
-        self.update_state(new_state, conversation_key)
+        if isinstance(self.map_to_parent, dict) and new_state in self.map_to_parent:
+            self.update_state(self.END, conversation_key)
+            return self.map_to_parent.get(new_state)
+        else:
+            self.update_state(new_state, conversation_key)
 
     def update_state(self, new_state, key):
         if new_state == self.END:


Test patch:
diff --git a/tests/test_conversationhandler.py b/tests/test_conversationhandler.py
--- a/tests/test_conversationhandler.py
+++ b/tests/test_conversationhandler.py
@@ -43,6 +43,10 @@ class TestConversationHandler(object):
     # and then we can start coding!
     END, THIRSTY, BREWING, DRINKING, CODING = range(-1, 4)
 
+    # Drinking state definitions (nested)
+    # At first we're holding the cup.  Then we sip coffee, and last we swallow it
+    HOLDING, SIPPING, SWALLOWING, REPLENISHING, STOPPING = map(chr, range(ord('a'), ord('f')))
+
     current_state, entry_points, states, fallbacks = None, None, None, None
     group = Chat(0, Chat.GROUP)
     second_group = Chat(1, Chat.GROUP)
@@ -69,6 +73,43 @@ def reset(self):
         self.fallbacks = [CommandHandler('eat', self.start)]
         self.is_timeout = False
 
+        # for nesting tests
+        self.nested_states = {
+            self.THIRSTY: [CommandHandler('brew', self.brew), CommandHandler('wait', self.start)],
+            self.BREWING: [CommandHandler('pourCoffee', self.drink)],
+            self.CODING: [
+                CommandHandler('keepCoding', self.code),
+                CommandHandler('gettingThirsty', self.start),
+                CommandHandler('drinkMore', self.drink)
+            ],
+        }
+        self.drinking_entry_points = [CommandHandler('hold', self.hold)]
+        self.drinking_states = {
+            self.HOLDING: [CommandHandler('sip', self.sip)],
+            self.SIPPING: [CommandHandler('swallow', self.swallow)],
+            self.SWALLOWING: [CommandHandler('hold', self.hold)]
+        }
+        self.drinking_fallbacks = [CommandHandler('replenish', self.replenish),
+                                   CommandHandler('stop', self.stop),
+                                   CommandHandler('end', self.end),
+                                   CommandHandler('startCoding', self.code),
+                                   CommandHandler('drinkMore', self.drink)]
+        self.drinking_entry_points.extend(self.drinking_fallbacks)
+
+        # Map nested states to parent states:
+        self.drinking_map_to_parent = {
+            # Option 1 - Map a fictional internal state to an external parent state
+            self.REPLENISHING: self.BREWING,
+            # Option 2 - Map a fictional internal state to the END state on the parent
+            self.STOPPING: self.END,
+            # Option 3 - Map the internal END state to an external parent state
+            self.END: self.CODING,
+            # Option 4 - Map an external state to the same external parent state
+            self.CODING: self.CODING,
+            # Option 5 - Map an external state to the internal entry point
+            self.DRINKING: self.DRINKING
+        }
+
     # State handlers
     def _set_state(self, update, state):
         self.current_state[update.message.from_user.id] = state
@@ -103,6 +144,23 @@ def passout(self, bot, update):
     def passout2(self, bot, update):
         self.is_timeout = True
 
+    # Drinking actions (nested)
+
+    def hold(self, bot, update):
+        return self._set_state(update, self.HOLDING)
+
+    def sip(self, bot, update):
+        return self._set_state(update, self.SIPPING)
+
+    def swallow(self, bot, update):
+        return self._set_state(update, self.SWALLOWING)
+
+    def replenish(self, bot, update):
+        return self._set_state(update, self.REPLENISHING)
+
+    def stop(self, bot, update):
+        return self._set_state(update, self.STOPPING)
+
     # Tests
     def test_per_all_false(self):
         with pytest.raises(ValueError, match="can't all be 'False'"):
@@ -609,3 +667,108 @@ def bye(bot, update):
             "If 'per_chat=True', 'InlineQueryHandler' can not be used,"
             " since inline queries have no chat context."
         )
+
+    def test_nested_conversation_handler(self, dp, bot, user1, user2):
+        self.nested_states[self.DRINKING] = [ConversationHandler(
+                                             entry_points=self.drinking_entry_points,
+                                             states=self.drinking_states,
+                                             fallbacks=self.drinking_fallbacks,
+                                             map_to_parent=self.drinking_map_to_parent)]
+        handler = ConversationHandler(entry_points=self.entry_points,
+                                      states=self.nested_states,
+                                      fallbacks=self.fallbacks)
+        dp.add_handler(handler)
+
+        # User one, starts the state machine.
+        message = Message(0, user1, None, self.group, text='/start', bot=bot,
+                          entities=[MessageEntity(type=MessageEntity.BOT_COMMAND,
+                                                  offset=0, length=len('/start'))])
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.THIRSTY
+
+        # The user is thirsty and wants to brew coffee.
+        message.text = '/brew'
+        message.entities[0].length = len('/brew')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.BREWING
+
+        # Lets pour some coffee.
+        message.text = '/pourCoffee'
+        message.entities[0].length = len('/pourCoffee')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.DRINKING
+
+        # The user is holding the cup
+        message.text = '/hold'
+        message.entities[0].length = len('/hold')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.HOLDING
+
+        # The user is sipping coffee
+        message.text = '/sip'
+        message.entities[0].length = len('/sip')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.SIPPING
+
+        # The user is swallowing
+        message.text = '/swallow'
+        message.entities[0].length = len('/swallow')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.SWALLOWING
+
+        # The user is holding the cup again
+        message.text = '/hold'
+        message.entities[0].length = len('/hold')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.HOLDING
+
+        # The user wants to replenish the coffee supply
+        message.text = '/replenish'
+        message.entities[0].length = len('/replenish')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.REPLENISHING
+        assert handler.conversations[(0, user1.id)] == self.BREWING
+
+        # The user wants to drink their coffee again
+        message.text = '/pourCoffee'
+        message.entities[0].length = len('/pourCoffee')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.DRINKING
+
+        # The user is now ready to start coding
+        message.text = '/startCoding'
+        message.entities[0].length = len('/startCoding')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.CODING
+
+        # The user decides it's time to drink again
+        message.text = '/drinkMore'
+        message.entities[0].length = len('/drinkMore')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.DRINKING
+
+        # The user is holding their cup
+        message.text = '/hold'
+        message.entities[0].length = len('/hold')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.HOLDING
+
+        # The user wants to end with the drinking and go back to coding
+        message.text = '/end'
+        message.entities[0].length = len('/end')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.END
+        assert handler.conversations[(0, user1.id)] == self.CODING
+
+        # The user wants to drink once more
+        message.text = '/drinkMore'
+        message.entities[0].length = len('/drinkMore')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.DRINKING
+
+        # The user wants to stop altogether
+        message.text = '/stop'
+        message.entities[0].length = len('/stop')
+        dp.process_update(Update(update_id=0, message=message))
+        assert self.current_state[user1.id] == self.STOPPING
+        assert handler.conversations.get((0, user1.id)) is None