diff --git a/AUTHORS.rst b/AUTHORS.rst index 9feb05969..63cd5e0bd 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -72,6 +72,7 @@ The following wonderful people contributed directly or indirectly to this projec - `Shelomentsev D `_ - `Simon Schürrle `_ - `sooyhwang `_ +- `syntx `_ - `thodnev `_ - `Trainer Jono `_ - `Valentijn `_ diff --git a/examples/README.md b/examples/README.md index d9c5aa9b2..3adda0430 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,9 @@ A common task for a bot is to ask information from the user. In v5.0 of this lib ### [`conversationbot2.py`](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/conversationbot2.py) A more complex example of a bot that uses the `ConversationHandler`. It is also more confusing. Good thing there is a [fancy state diagram](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/conversationbot2.png) for this one, too! +### [`nestedconversationbot.py`](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/nestedconversationbot.py) +A even more complex example of a bot that uses the nested `ConversationHandler`s. While it's certainly not that complex that you couldn't built it without nested `ConversationHanldler`s, it gives a good impression on how to work with them. Of course, there is a [fancy state diagram](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/nestedconversationbot.png) for this example, too! + ### [`inlinekeyboard.py`](https://github.com/python-telegram-bot/python-telegram-bot/blob/master/examples/inlinekeyboard.py) This example sheds some light on inline keyboards, callback queries and message editing. diff --git a/examples/nestedconversationbot.png b/examples/nestedconversationbot.png new file mode 100644 index 000000000..4337040e6 Binary files /dev/null and b/examples/nestedconversationbot.png differ diff --git a/examples/nestedconversationbot.py b/examples/nestedconversationbot.py new file mode 100644 index 000000000..263841cc3 --- /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 index 263f6585c..a53f4e81c 100644 --- a/telegram/ext/conversationhandler.py +++ b/telegram/ext/conversationhandler.py @@ -64,6 +64,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. @@ -88,6 +102,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 @@ -119,6 +136,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 @@ -142,7 +162,8 @@ class ConversationHandler(Handler): per_message=False, conversation_timeout=None, name=None, - persistent=False): + persistent=False, + map_to_parent=None): self.entry_points = entry_points self.states = states @@ -160,6 +181,7 @@ class ConversationHandler(Handler): 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() @@ -328,7 +350,11 @@ class ConversationHandler(Handler): 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: diff --git a/tests/test_conversationhandler.py b/tests/test_conversationhandler.py index ad870f4d6..269cb895d 100644 --- 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 @@ class TestConversationHandler(object): 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 @@ class TestConversationHandler(object): 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 @@ class TestConversationHandler(object): "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