📜 ⬆️ ⬇️

We write interactive Telegram bots on Python

I think everyone here is more or less aware of the Telegram messenger. The creator declares that it is the safest messenger with a self-developed killer encryption algorithm, but we, the developers, of course, are much more interested in something else. Bots!

This theme, of course, did not just rise on Habré: bots were written in Python with tornado , Node.js , Ruby with a special gem , Ruby on Rails , C # , C # with WCF, and even PHP ; bots wrote for RSS feeds , monitoring sites , remotely turning on the computer and, probably, for many, many other things.

And yet I will take the liberty to travel this topic again and in addition to this show some Python magic. We will write the framework ™ for easy writing non-trivial conversational bots based on the python-telegram-bot package.

How to conceive a bot?


This question is best answered by official documentation . The process looks like this:
')


Simple, isn't it? (Be prudent and do not take good nicknames without a good reason!)

The easiest bot


First, we will look at the tutorial of our basic package in order to understand how a simple bot begins. Following code

# -*- coding: utf-8 -*- from telegram.ext import Updater #   python-telegram-bot,  Python- from telegram.ext import CommandHandler #  -  telegram ¯\_(ツ)_/¯ def start(bot, update): #    update: https://core.telegram.org/bots/api#update bot.sendMessage(chat_id=update.message.chat_id, text=".") updater = Updater(token='TOKEN') #  ,     ! start_handler = CommandHandler('start', start) #    #    /start updater.dispatcher.add_handler(start_handler) #     updater.start_polling() # ! 

creates a bot that dryly answers “Hello.” when you click on the Start button (or manually enter the /start command) and is meaningfully silent during any subsequent actions on your part.

Accordingly, if we want to hang up the handlers of any text messages or any commands, we will need to write

 from telegram.ext import Filters, MessageHandler def handle_text(bot, update): # ... def handle_command(bot, update): # ... # MessageHandler --   ,      text_handler = MessageHandler(Filters.text, self.handle_text) command_handler = MessageHandler(Filters.command, self.handle_command) #      updater.dispatcher.add_handler(text_handler) #    , updater.dispatcher.add_handler(command_handler) #      () 

(For further details, with a clear conscience, refer to the documentation python-telegram-bot .)

Loaded with this theoretical minimum, we can finally think about how to write our nontrivial bot. First, let's get back to the problem statement. By a dialogue bot, I mean a bot that mostly conducts a simple textual dialogue with a user — with questions, answers, a non-linear storyline, disappointing endings, and everyone like that (played “ Endless Summer ”?). On the contrary, they do not fall into the sphere of our current interests bots that extend Telegram functionality in various ways (like a bot for likes ); accordingly, we omit the addition of all sorts of buns like inline mode , games , updating controls on the fly and all that stuff.

The problem with complex conversational bots is that non-trivial dialogue requires state storage. The work of asynchronous dialogs requires constant interruptions to wait for a message from the user; the state must be saved, then restored, jumped to the code responsible for processing the next message, and so on; in general, code organization becomes a rather depressing problem. Interrupt, continue ... nothing like? Well, let's see how this problem can be more gracefully circumvented using magic yield .

50 shades yield


What do we know about yield ? Well, we all know that this is such a thing to write generators, that is, such lazy and potentially endless lists:

 def fibonacci(): a, b = 1, 1 while True: yield a a, b = b, a + b f = fibonacci() 

Now the object f is such a magic box; it is necessary to put a hand into it to write next(f) , and we get the next Fibonacci number, but it is worth turning it up to write list(f) , as we go into an infinite loop, which most likely ends in a tragic death of the system from a lack of RAM.

We know that generators are fast, convenient and very Python-style. We have a module itertools , offering generators for every taste and color. But we have something else.

Where the less well known skills of the word yield are the abilities ... to return values ​​and throw exceptions! Yes, yes, if we write:

 f.throw(Exception) 

That calculation of Fibonacci numbers will break in the most tragic way - the exception in the line with yield .

In turn, calling f.send(something) will force the yield construct to return a value, and then immediately return next(f) . It is enough to equate the yield variable in order to catch the passed value:

 def doubler(start): while True: start = yield (start, start) d = doubler(42) print(next(d)) #  (42, 42) print(next(d)) #  (None, None),     - ! print(d.send(43)) #  (43, 43) -- yield  43,      yield 

But that's not all. Starting in Python 3.3, generators can delegate execution to each other using the yield from construct: instead of

 def concatenate(iterable1, iterable2): for item in iterable1: yield item for item in iterable2: yield item 

she lets us write

 def concatenate(iterable1, iterable2): yield from iterable1 yield from iterable2 

But it would be an understatement to say that yield from allows us to only save lines of code on cycles. The fact is that it also takes care of send and throw — when called, they will interact not with the function concatenate , but with one of two generators to which it transfers control. (If it turns out not to be generators ... well, oops.)

And yield from also knows how to return a value: for this, the right to non-trivial (that is, returning something, and not just terminating execution) return generator functions:

 def despaired_person(): yield None yield None yield None return "I'm tired of my uselessness" def despair_expresser(): result = yield from despaired_person() print(result) print(list(f())) #  # I'm tired of my uselessness # [None, None, None] 

Why am I doing all this? Oh yes. These tricks, taken together, will allow us to easily and naturally write our interactive bots.

We write a wrapper


So, let the dialogue with each user is conducted by the generator. yield will issue a message to the outside, which must be sent to the user, and return the answer to it (as soon as it appears). Let's write a simple class that knows how to do it.

 import collections from telegram.ext import Filters from telegram.ext import MessageHandler from telegram.ext import Updater class DialogBot(object): def __init__(self, token, generator): self.updater = Updater(token=token) #   handler = MessageHandler(Filters.text | Filters.command, self.handle_message) self.updater.dispatcher.add_handler(handler) #      self.handlers = collections.defaultdict(generator) #   "id  -> " def start(self): self.updater.start_polling() def handle_message(self, bot, update): print("Received", update.message) chat_id = update.message.chat_id if update.message.text == "/start": #    /start,     --  #     ,    self.handlers.pop(chat_id, None) if chat_id in self.handlers: #    ,    .send(),  #      try: answer = self.handlers[chat_id].send(update.message) except StopIteration: #      --  ,     del self.handlers[chat_id] # ( ,    ,     ) return self.handle_message(bot, update) else: #   . defaultdict      # ,          .next() # (.send()     yield) answer = next(self.handlers[chat_id]) #     print("Answer: %r" % answer) bot.sendMessage(chat_id=chat_id, text=answer) 

Well, it remains to compose the dialogue, which we will play! Let's talk about Python.

 def dialog(): answer = yield "!    ,    ?" #    ,   #   ,      name = answer.text.rstrip(".!").split()[0].capitalize() likes_python = yield from ask_yes_or_no(" , %s.   ?" % name) if likes_python: answer = yield from discuss_good_python(name) else: answer = yield from discuss_bad_python(name) def ask_yes_or_no(question): """    ,  «»  «». : bool """ answer = yield question while not ("" in answer.text.lower() or "" in answer.text.lower()): answer = yield "   ?" return "" in answer.text.lower() def discuss_good_python(name): answer = yield "  , %s,  !       ?" % name likes_article = yield from ask_yes_or_no(".   , ,   ? ?") if likes_article: answer = yield "!" else: answer = yield "." return answer def discuss_bad_python(name): answer = yield "--. %s,   !      ?" % name likes_article = yield from ask_yes_or_no( "     .  " "  ,  ,   ?") if likes_article: answer = yield "  ." else: answer = yield " «»? «,  »  «, »?" answer = yield ",     ." return answer if __name__ == "__main__": dialog_bot = DialogBot(sys.argv[1], dialog) dialog_bot.start() 

And it works! The result looks like this:



Add markup


Telegram bots are strong because they can throw HTML and Markdown markup into their users; this opportunity to pass us by would be impermissible. To understand how to send a markup message, let's take a look at the description of the Bot.sendMessage function:

  def sendMessage(self, chat_id, text, parse_mode=None, disable_web_page_preview=None, disable_notification=False, reply_to_message_id=None, reply_markup=None, timeout=None, **kwargs): """Use this method to send text messages. Args: chat_id (str): ... text (str): ... parse_mode (Optional[str]): Send Markdown or HTML, if you want Telegram apps to show bold, italic, fixed-width text or inline URLs in your bot's message. disable_web_page_preview (Optional[bool]): ... disable_notification (Optional[bool]): ... reply_to_message_id (Optional[int]): ... reply_markup (Optional[:class:`telegram.ReplyMarkup`]): Additional interface options. A JSON-serialized object for an inline keyboard, custom reply keyboard, instructions to hide reply keyboard or to force a reply from the user. timeout (Optional[float]): ... ... """ 

Aha It is enough to pass the argument parse_mode="HTML" or parse_mode="Markdown" . One could just add this to our challenge, but let's make it a bit more deadly: add special objects that need to be raised to trigger the use of markup:

 class Message(object): def __init__(self, text, **options): self.text = text self.options = options class Markdown(Message): def __init__(self, text, **options): super(Markup, self).__init__(text, parse_mode="Markdown", **options) class HTML(Message): def __init__(self, text, **options): super(HTML, self).__init__(text, parse_mode="HTML", **options) 

Now sending messages will look like this:

 def handle_message(self, bot, update): # ...... print("Answer: %r" % answer) self._send_answer(bot, chat_id, answer) def _send_answer(self, bot, chat_id, answer): if isinstance(answer, str): answer = Message(answer) bot.sendMessage(chat_id=chat_id, text=answer.text, **answer.options) 

To demonstrate, let's modify ask_yes_or_no() :

 def ask_yes_or_no(question): answer = yield question while not ("" in answer.text.lower() or "" in answer.text.lower()): answer = yield HTML(" <b></b>  <b></b>?") return "" in answer.text.lower() 

The result is obvious:



Add buttons


The only thing we lack and that would be quite useful for writing interactive bots is a keyboard with a choice of answer options. To create a keyboard, we just need to add the reply_markup key to reply_markup ; but let's try to simplify and abstract our code inside the generators as much as possible. Here the decision is easier. Let, for example, yield returns not one object, but several at once; if among them there is a list or a list of lists with lines, for example:

 answer = yield ( "   1  9", [ ["1", "2", "3"], ["4", "5", "6"], ["7", "8", "9"], ] ) 

, then we think that these are keyboard buttons, and we want to get something like the following result:



_send_answer() then converted to something like this:

 def _send_answer(self, bot, chat_id, answer): print("Sending answer %r to %s" % (answer, chat_id)) if isinstance(answer, collections.abc.Iterable) and not isinstance(answer, str): #     --     answer = list(map(self._convert_answer_part, answer)) else: #     --      answer = [self._convert_answer_part(answer)] #  ,    ,     # «» --       - current_message = None for part in answer: if isinstance(part, Message): if current_message is not None: #     ,    #    (   ) options = dict(current_message.options) options.setdefault("disable_notification", True) bot.sendMessage(chat_id=chat_id, text=current_message.text, **options) current_message = part if isinstance(part, ReplyMarkup): # ,    !   . #   --  ,  . current_message.options["reply_markup"] = part #       . if current_message is not None: bot.sendMessage(chat_id=chat_id, text=current_message.text, **current_message.options) def _convert_answer_part(self, answer_part): if isinstance(answer_part, str): return Message(answer_part) if isinstance(answer_part, collections.abc.Iterable): # ? answer_part = list(answer_part) if isinstance(answer_part[0], str): # !     . # ,     --   . return ReplyKeyboardMarkup([answer_part], one_time_keyboard=True) elif isinstance(answer_part[0], collections.abc.Iterable): #  ? if isinstance(answer_part[0][0], str): # ! return ReplyKeyboardMarkup(map(list, answer_part), one_time_keyboard=True) return answer_part 

As a demonstration, change the ask_yes_or_no() and discuss_bad_python() :

 def ask_yes_or_no(question): """    ,  «»  «». : bool """ answer = yield (question, [".", "."]) while not ("" in answer.text.lower() or "" in answer.text.lower()): answer = yield HTML(" <b></b>  <b></b>?") return "" in answer.text.lower() def discuss_bad_python(name): answer = yield "--. %s,   !      ?" % name likes_article = yield from ask_yes_or_no( "     .  " "  ,  ,   ?") if likes_article: answer = yield "  ." else: answer = yield ( " «»? «,  »  «, »?", [",  !", ", !"] ) answer = yield ",     ." return answer 

Result:



Conclusion


Generators in Python are a powerful tool, and using it in the right situations can significantly reduce and simplify code. See how beautifully we, for example, carried the question “yes or no” into a separate function, while leaving him the right to conduct additional communication with the user. We could also put a name in question into a separate function, and teach him to clarify with the user, whether we understood him correctly, and so on and so forth. The generators themselves keep the state of the dialogue for us, and they themselves are able to continue it from the required moment. All for us!

I hope this article was useful to someone. As usual, feel free to report all typos, spelling and grammatical errors in PM. All the code for the article is in the repository on Github ( habrahabr-316666 ). I will not give the link to the bot and I will not keep it alive, of course, in the near future, otherwise the habraeffect will cover it with my computer. Successes in creating their interactive bots 😉

Source: https://habr.com/ru/post/316666/


All Articles