I'm making a sort of a user interface for a telegram bot and my idea was to use State design pattern for making sort of a wizard for all the process, with user inputting data in each step or pressing /skip for skipping a single step or /cancel for ending everything.
It's working pretty well. I put the states/steps in a list so I can always get to the next one easily by a common method in the parent. Every state is a Borg, so I can instantiate a state everywhere and will always have the same values and methods.
I also made a Singleton class named Progress
for putting all the input in the wizard process so I can store it and send it altogether at the end. I know you can use modules in Python as singleton, but I'm more used to this and I prefer not polluting the namespace. I also think this is easier for testing and using.
Being a Python beginner, I'm proud with this code, but I think it has some code smells and it can be improved in terms of design and best practices. I also made it for a single user, but haven't tested two persons accessing the bot simultaneously. I'd like to restrict transitions among states too.
step_states.py:
from abc import ABCMeta, abstractmethod
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, ReplyKeyboardRemove
from telegram.ext import ConversationHandler
from my_package.amazon.search_indexes import SEARCH_INDEXES
from environments import get_blogs
OK = 'OK'
CANCEL = 'CANCEL'
DELETE = 'DELETE'
class StepState(metaclass=ABCMeta):
"""
This is a Borg class which shares state and methods so all the instances created for it are different references
but containing the same values. This is also true for children of the same class, so:
Children1() == Children1() # False, because references are different, but the values are the same.
Also if you change one, you change the other
Children1() == Children2() # False, references are different and you can change anyone without affecting the other
"""
states = []
name = "state" # This is not used yet. Thinking of deleting it everywhere
allowed = [] # This is not used, but could be interesting to allow only some transitions. Just a sketch
# State is a Borg design pattern class
__shared_state = {}
def __init__(self):
self.__dict__ = self.__shared_state
def __eq__(self, other):
return self.__hash__() == other.__hash__()
def message(self, update):
"""
Returns the object holding the info coming from Telegram with the methods for replying and looking into this info
:param update: the Telegram update, probably a text message
:return: the update or query object
"""
if update.callback_query:
return update.callback_query.message
else:
return update.message
def draw_ui(self, bot, update):
"""
Draws the UI in Telegram (usually a custom keyboard) for the user to input data
:param bot: the Telegram bot
:param update: the Telegram update, probably a text message
"""
pass
@abstractmethod
def handle(self, bot=None, update=None):
"""
Handles all the input info into the Progress object and transitions to the next state
:param bot: the Telegram bot
:param update: the Telegram update, probably a text message
"""
self.progress = Progress()
# TODO How to make cancel() common to all states
#query = update.callback_query
#if query.data == CANCEL:
# return cancel(bot, update)
def next_state(self, new_state=None):
"""
Inherited method which decides to move to the next state, or to the specified state if any
:param new_state: the next state, if any
:return: the next state
"""
# TODO Check if the transition is allowed?
if new_state is None:
default_next_state_index = (self.index + 1) % len(self.states)
next_new_state = self.states[default_next_state_index]
# TODO logging
logger.info('Current state:', self, ' => switched to new state', next_new_state)
return next_new_state
else:
return new_state
@property
def index(self):
"""
Returns the index of this state within the list with all states
:return: the index as an int
"""
return StepState.states.index(self)
def __hash__(self):
return hash(self.__class__.name)
def __str__(self):
return self.__class__.name
class NoneState(StepState):
name = 'NoneState'
def draw_ui(self, bot, update):
pass
def handle(self, bot=None, update=None):
pass
class DepState(StepState):
name = 'DepState'
def draw_ui(self, bot, update):
reply_keyboard = [
]
num_cols = 3
current_row = []
# Think of SEARCH_INDEXES as a list with objects having two strings: spanish_description and browse_node_id
for dep in SEARCH_INDEXES:
current_row.append(InlineKeyboardButton(dep.spanish_desc,
callback_data="{}={}".format(dep.browse_node_id, dep.spanish_desc)))
if len(current_row) >= num_cols:
reply_keyboard.append(current_row)
current_row = []
reply_markup = InlineKeyboardMarkup(reply_keyboard)
self.message(update).reply_text('Enter the department or /cancel', reply_markup=reply_markup)
def handle(self, bot=None, update=None):
super().handle()
query = update.callback_query
if query.data:
current_input_dep, text = query.data.split('=')
bot.edit_message_text(text="Departament chosen: {}".format(text), chat_id=query.message.chat_id,
message_id=query.message.message_id)
# We optionally log anything
Progress().input_dep = current_input_dep
Progress().next_state()
Progress().state.draw_ui(bot, update)
return Progress().state
class NodeState(StepState):
name = 'NodeState'
def draw_ui(self, bot, update):
reply_keyboard = [
[InlineKeyboardButton(str(number), callback_data=str(number)) for number in range(5)],
[InlineKeyboardButton(str(number), callback_data=str(number)) for number in range(5, 10)],
[InlineKeyboardButton('Accept', callback_data=OK),
InlineKeyboardButton('Cancel', callback_data=CANCEL)],
]
reply_markup = InlineKeyboardMarkup(reply_keyboard)
self.message(update).reply_text('Input numbers for the node or /cancel', reply_markup=reply_markup)
# We get the reference to the message we will use to update the value and set into Progress
Progress().tracking_message = self.message(update).reply_text("Typed data : {}".format(''))
def handle(self, bot=None, update=None):
super().handle()
query = update.callback_query
if query.data == OK:
# TODO Validate input data
#logger.info('Input data: {}'.format(self.progress.input_node))
bot.edit_message_text(text="Chosen node: {}".format(self.progress.input_node), chat_id=query.message.chat_id,
message_id=query.message.message_id)
bot.delete_message(chat_id=Progress().tracking_message.chat_id,
message_id=Progress().tracking_message.message_id)
Progress().next_state()
Progress().state.draw_ui(bot, update)
return Progress().state
else:
if not self.progress.input_node:
self.progress.input_node = ''
if query.data == DELETE:
if len(self.progress.input_node):
self.progress.input_node = self.progress.input_node[:-1]
else:
self.progress.input_node += query.data
# We update the output so the user sees if he types correctly
bot.edit_message_text(text="Input data: {}".format(self.progress.input_node),
chat_id=query.message.chat_id,
message_id=Progress().tracking_message.message_id)
class BlogState(StepState):
name = 'BlogState'
def draw_ui(self, bot, update):
reply_keyboard = [
[InlineKeyboardButton(blog, callback_data='{}={}'.format(blog, bid)) for blog, bid in get_blogs().items()]
]
reply_markup = InlineKeyboardMarkup(reply_keyboard)
self.message(update).reply_text('Choose an option, /skip or /cancel', reply_markup=reply_markup)
def handle(self, bot=None, update=None):
super().handle()
query = update.callback_query
if query.data:
bname, bid = query.data.split('=')
bot.edit_message_text(text="Chosen option: {}".format(bname), chat_id=query.message.chat_id,
message_id=query.message.message_id)
# We optionally log anything
# We text the user the requirements for next state
# query.message.reply_text('Departamento elegido. ', reply_markup=ReplyKeyboardRemove())
Progress().input_blog = (bname,bid)
Progress().next_state()
Progress().state.draw_ui(bot, update)
return Progress().state
class StartTimeState(StepState):
name = 'StartTimeState'
def draw_ui(self, bot, update):
reply_keyboard = [
[InlineKeyboardButton('Delete', callback_data=DELETE)],
[InlineKeyboardButton(str(number), callback_data=str(number)) for number in range(5)],
[InlineKeyboardButton(str(number), callback_data=str(number)) for number in range(5, 10)],
[InlineKeyboardButton(':', callback_data=':')],
[InlineKeyboardButton('Accept', callback_data=OK),
InlineKeyboardButton('Cancel', callback_data=CANCEL)],
]
reply_markup = InlineKeyboardMarkup(reply_keyboard)
self.message(update).reply_text('Input start time or /cancel', reply_markup=reply_markup)
# We get the reference to the message we will use to update the value and set into Progress
Progress().tracking_message = self.message(update).reply_text("Start time: {}".format(''))
def handle(self, bot=None, update=None):
super().handle()
query = update.callback_query
if query.data == OK:
# TODO Validate input data
#logger.info('Intpu data: {}'.format(self.progress.input_start_time))
bot.delete_message(chat_id=query.message.chat_id, message_id=query.message.message_id)
#bot.edit_message_text(text="Input start time: {}".format(self.progress.input_start_time),chat_id=query.message.chat_id,message_id=query.message.message_id)
#bot.delete_message(chat_id=Progress().tracking_message.chat_id,message_id=Progress().tracking_message.message_id)
Progress().next_state()
Progress().state.draw_ui(bot, update)
return Progress().state
else:
if not self.progress.input_start_time:
self.progress.input_start_time = ''
if query.data == DELETE:
if len(self.progress.input_start_time):
self.progress.input_start_time = self.progress.input_start_time[:-1]
else:
self.progress.input_start_time += query.data
# We update the output so the user sees if he types correctly
bot.edit_message_text(text="Start time: {}".format(self.progress.input_start_time),
chat_id=query.message.chat_id,
message_id=Progress().tracking_message.message_id)
# ... more states
# ... more and more states
# ...and so on, you can imagine
class KeywordsState(StepState):
name = 'KeywordsState'
def draw_ui(self, bot, update):
self.message(update).reply_text('Enter keywords', reply_markup=ReplyKeyboardRemove())
def handle(self, bot=None, update=None):
#logger.info("Keywords: %s", update.message.text)
# TODO Store in a variable or similar the keywords
update.message.reply_text(
'Thanks. The keywods are: {}'.format(update.message.text))
Progress().next_state()
Progress().state.draw_ui(bot, update)
return Progress().state
class ConfirmState(StepState):
name = 'ConfirmState'
def draw_ui(self, bot, update):
self.message(update).reply_text('Confirm all this info: {!s}'.format(Progress()))
reply_keyboard = [
[InlineKeyboardButton('Accept', callback_data=OK),
InlineKeyboardButton('Cancel', callback_data=CANCEL)],
]
reply_markup = InlineKeyboardMarkup(reply_keyboard)
self.message(update).reply_text('Accep or cancel (/cancel)', reply_markup=reply_markup)
def handle(self, bot=None, update=None):
query = update.callback_query
self.message(update).reply_text('Finalizado')
# Se lanza el procesamiento
# Se resetea el progreso
self.progress.clear()
Progress().next_state(NoneState())
return ConversationHandler.END
# This is important. It's intended for setting the steps in an ordered-sorted way
StepState.states = [NoneState(), DepState(), NodeState(), BlogState(), IntervalState(), StartTimeState(), EndTimeState(), RepeatsState(), LabelsState(), KeywordsState(), ConfirmState()]
class Progress(object):
"""
This singleton class contains the whole information collected along all the wizard process
"""
__instance = None
def __new__(cls):
if Progress.__instance is None:
Progress.__instance = object.__new__(cls)
Progress.__instance.input_blog = None
Progress.__instance.state = NoneState()
Progress.__instance.tracking_message = None
Progress.__instance.input_dep = None
Progress.__instance.input_node = None
Progress.__instance.input_minutes = None
Progress.__instance.input_start_time = None
Progress.__instance.input_end_time = None
return Progress.__instance
def clear(self):
"""
Resets the progress
"""
self.input_blog = None
self.state = NoneState()
self.tracking_message = None
self.input_dep = None
self.input_node = None
self.input_minutes = None
self.input_start_time = None
self.input_end_time = None
def next_state(self, new_state=None):
"""
Moves to the next state or to the specified state if any
:param new_state: the next state to move to
:return: the next state, already set to the Progress object
"""
if new_state is not None:
if self.state:
self.state = self.state.next_state(new_state)
else:
self.state = NoneState
#logger('Couldnt go to next state')
else:
self.state = self.state.next_state()
def __str__(self):
return '{}: dep={}, node={}, start={},end={}'.format(self.__class__.__name__, self.input_dep, self.input_node, self.input_start_time, self.input_end_time)
Now we use the previous code here:
bot_prototype:
#!/usr/bin/python3
from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove
from telegram.ext import (Updater, Filters, RegexHandler, CommandHandler,
CallbackQueryHandler, ConversationHandler, MessageHandler)
from my_package.step_states import DepState, NodeState, StartTimeState, EndTimeState, KeywordsState, ConfirmState, \
BlogState, IntervalState
from my_package.step_states import Progress
from environments import get_bot_token
import logging
logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO)
logger = logging.getLogger(__name__)
OK = 'OK'
CANCEL = 'CANCEL'
DELETE = 'DELETE'
# Updater is telegram code and the bot_token is an id string
updater = Updater(get_bot_token())
def start(bot, update):
"""
Starts the wizard planning process
:param bot: the Telegram bot
:param update: the Telegram update, probably a text message
:return: the state for the ConversationHandler
"""
p = Progress()
p.next_state()
p.state.draw_ui(bot, update)
return p.state
def error(bot, update, error):
"""
Logs errors
:param bot: the Telegram bot
:param update: the Telegram update, probably a text message
"""
logger.error('Update "%s" caused error "%s"', update, error)
def skip(bot, update):
"""
Skips this step in the wizard process
:param bot: the Telegram bot
:param update: the Telegram update, probably a text message
:return: the next state skipping the present one
"""
user = update.message.from_user
logger.info("%s skipped a step.", user.first_name)
update.message.reply_text("Skipping steps is not supported yet. Process is done")
# If we don't cancel at the end, we should remove any keyboard which could be present
# TODO Right now we don't accept skips and we cancel everything.
# TODO In the future, we will look at present state and choose the next state
return cancel(bot, update)
def cancel(bot, update):
"""
Cancels the whole wizard process
:param bot: the Telegram bot
:param update: the Telegram update, probably a text message
:return: the END state for he ConversationHandler
"""
Progress().clear()
user = update.message.from_user
logger.info("{} cancelled the process.".format(user.first_name))
update.message.reply_text('Process ended.', reply_markup=ReplyKeyboardRemove())
return ConversationHandler.END
def main():
global updater
dp = updater.dispatcher
# Add conversation handler with the states
# The telegram conversation handler needs a list of handlers with function so it can execute desired code in each state/step
conv_handler = ConversationHandler(
entry_points=[ CommandHandler('start', start) ],
states={
DepState(): [CallbackQueryHandler(DepState().handle), CommandHandler('skip', skip)],
NodeState(): [CallbackQueryHandler(NodeState().handle), CommandHandler('skip', skip)],
BlogState(): [CallbackQueryHandler(BlogState().handle), CommandHandler('skip', skip)],
IntervalState(): [CallbackQueryHandler(IntervalState().handle), CommandHandler('skip', skip)],
StartTimeState(): [CallbackQueryHandler(StartTimeState().handle), CommandHandler('skip', skip)],
EndTimeState(): [CallbackQueryHandler(EndTimeState().handle), CommandHandler('skip', skip)],
KeywordsState(): [MessageHandler(Filters.text, KeywordsState().handle), CommandHandler('skip', skip)],
ConfirmState(): [CallbackQueryHandler(ConfirmState().handle)],
},
fallbacks=[CommandHandler('cancel', cancel)]
)
dp.add_handler(conv_handler)
# dp.add_handler(CallbackQueryHandler(button_callback))
updater.dispatcher.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()