Пишемо діалогові Telegram-боти на Пітоні

Думаю, всім тут в тій чи іншій мірі відомий месенджер Telegram. Творець заявляє, що це самий безпечний месенджер з забійним алгоритмом шифрування власної розробки, але нас, розробників, звичайно ж, набагато більше цікавить інше. Боти!

Тема ця, звичайно, не раз піднімалася на Хабре: ботів писали Python tornado, Node.js, Ruby зі спеціальним гемом, Ruby on Rails, C# C# з WCF і навіть PHP; ботів писали для RSS, моніторингу сайтів, віддаленого включення комп'ютера і, ймовірно, для багато чого іншого.

І все ж я візьму на себе сміливість об'їздити цю тему ще раз і додатково до цього показати трохи магії Пітона. Ми будемо писати фреймворк™ для зручного написання нетривіальних діалогових ботів на основі пакету python-telegram-bot.

Як зачати бота?
На це питання найкраще відповідає офіційна документація. Виглядає процес приблизно так:



Просто, чи не правда? (Будьте розсудливі і не займайте хороші никнеймы без переконливої причини!)

найпростіший бот
Спершу глянемо туториал нашого базового пакета, щоб зрозуміти, з чого починається простенький бот. Наступний код

# -*- 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() # поїхали!

створює бота, який сухо відповідає «Привіт.» при натисненні на кнопку Start (або ручному введенні команди
/start
) і багатозначно мовчить при будь-яких подальших дій з вашого боку.

Відповідно, якщо ми захочемо повісити обробники будь-яких текстових повідомлень або будь-яких команд, нам потрібно буде написати

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) # але не більше трьох місяців (жарт)

(За дальшими подробицями з чистою совістю відсилаю до документації python-telegram-bot.)

Навантажені цим теоретичним мінімумом, ми можемо нарешті подумати, як нам писати свого нетривіального бота. Для початку давайте повернемося до постановки задачі. Під діалоговим ботом я маю на увазі бота, який головним чином веде звичайний текстовий діалог з користувачем   з питаннями, відповідями, нелінійним сюжетом, розчаровують кінцівками і все в такому дусі (грали в «Нескінченне літо»?) Навпаки, не потрапляють в сферу наших поточних інтересів боти, різним чином розширюють функціонал Telegram (зразок бота для лайків); відповідно, ми опустимо додавання всяких штучок на зразок инлайнового режиму, ігор оновлення елементів управління на льоту і всього такого іншого.

Проблема складних діалогових ботів в тому, що нетривіальний діалог вимагає зберігання стану. Робота асинхронних діалогів вимагає постійних переривань на очікування повідомлення від користувача; стан потрібно зберігати, потім відновлювати, стрибати до коду, відповідального за обробку чергового повідомлення, і так далі; загалом, організація коду стає проблемою досить гнітючою. Перервати, продовжити… нічого не нагадує? Що ж, подивимося, як означену проблему можна изящнейше обійти за допомогою магії
yield
.

50 відтінків yield
Що ми знаємо про
yield
? Ну, всі ми знаємо, що це така штука, щоб писати генератори, тобто такі ліниві і потенційно нескінченні списки:

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

f = fibonacci()

Тепер об'єкт
f
 — це така чарівна коробка; варто всунути в неї руку написати
next(f)
, і ми отримаємо чергове число Фібоначчі, але варто перевернути її написати
list(f)
, як ми підемо в нескінченний цикл, який, швидше за все, закінчиться трагічною смертю системи від нестачі оперативної пам'яті.

Ми знаємо, що генератори — це швидко, зручно і дуже в стилі Python. У нас є модуль
itertools
, пропонує генератори на будь-який смак і колір. Але у нас є дещо ще.

Куди менш відомими навичками слова
yield
є здібності… повертати значення і кидати винятку! Так-так, якщо ми напишемо:

f.throw(Exception)
, то обчислення чисел Фібоначчі обірветься самим трагічним чином   винятком у рядку
yield
.

У свою чергу виклик
f.send(something)
змусить конструкцію
yield
повернути значення, а потім відразу поверне
next(f)
. Досить прирівняти
yield
змінну, щоб вказане значення зловити:

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

Але і це ще не все. Починаючи з Python 3.3, генератори вміють делегувати виконання один одному за допомогою конструкції
yield from
замість

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

вона дозволяє нам писати

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

Але було б недостатньо сказати, що
yield from
дозволяє нам лише заощадити рядка коду на циклах. Справа в тому, що вона також дбає про
send
та
throw
 — при виклику вони будуть взаємодіяти не з функцією
concatenate
, а з одним з двох генераторів, яким вона передає управління. (Якщо це виявляться не генератори… ну упс.)

А ще
yield from
теж вміє повертати значення: для цього функцій-генераторам повернули право на нетривіальний (тобто повертає щось, а не просто закінчує виконання)
return
:

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]

До чого я все це? Ах так. Ці фокуси, разом узяті, дозволять нам легко і природно писати наших діалогових ботів.

Пишемо обгортку
Отже, нехай діалог з кожним користувачем ведеться генератором.
yield
видаватиме назовні повідомлення, яке потрібно надіслати користувачу, і повертати всередину його відповідь (як тільки він з'явиться). Давайте напишемо простенький клас, який вміє це робити.

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)

Що ж, залишилося скласти діалог, який ми будемо відігравати! Давайте поговоримо про Пітоні.


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()

І це працює! Результат виглядає приблизно так:



Додаємо розмітку
Боти в Telegram сильні тим, що можуть кидатися в своїх користувачів HTML і Markdown-розміткою; цю можливість обійти стороною нам було б неприпустимо. Щоб зрозуміти, як послати повідомлення з розміткою, давайте поглянемо на опис функції
Bot.sendMessage
:

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]): ...
...
"""

Ага! Досить передавати аргумент
parse_mode="HTML"
або
parse_mode="Markdown"
. Можна було б просто додати це наш виклик, але давайте зробимо трохи гнучкіша: додамо спеціальні об'єкти, які потрібно буде yield'ить, щоб спровокувати використання розмітки:

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)

Тепер відправка повідомлень буде виглядати так:


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)

Для демонстрації давайте модифікуємо
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()

Результат очевидний:



Додаємо кнопки
Єдине, чого нам не вистачає і що могло б цілком собі стати в нагоді при написанні діалогових ботів   клавіатура з вибором варіантів відповіді. Для створення клавіатури нам досить додати в
Message.options
ключ
reply_markup
; але давайте постараємося максимально спростити і абстрагувати наш код всередині генераторів. Тут напрошується рішення простіше. Нехай, наприклад,
yield
видає не один об'єкт, а відразу декілька; якщо серед них є список або список списків з рядками, наприклад:

answer = yield (
"Натисніть цифру від 1 до 9",
[
["1", "2", "3"],
["4", "5", "6"],
["7", "8", "9"],
]
)

, то ми вважаємо, що це кнопки клавіатури, і хочемо отримати приблизно наступний результат:



_send_answer()
тоді перетвориться в щось таке:

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

В якості демонстрації поміняємо
ask_yes_or_no()
та
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

Результат:



Висновок
Генератори в Пітоні   потужний інструмент, і використання його в потрібних ситуаціях дозволяє значно скорочувати і спрощувати код. Подивіться, як гарно ми, наприклад, винесли питання «так чи ні» в окрему функцію, притому залишивши за ним право проводити додаткове спілкування з користувачем. Так само ми могли б винести в окрему функцію і вопрошание імені, і навчити його уточнювати у користувача, вірно ми його зрозуміли, і так далі, і тому подібне. Генератори самі зберігають за нас стан діалогу, і самі вміють продовжувати необхідного моменту. Все для нас!

Сподіваюся, ця стаття була комусь корисною. Як водиться, не соромтеся повідомляти про всі опечатки, орфографічних і граматичних помилок в лічку. Весь код до статті лежить в репозиторії на Github. На бота посилання не дам і живим його тримати, звичайно, найближчим часом не буду, інакше хабраэффект накриє його разом з моїм комп'ютером. Успіхів у створенні своїх діалогових ботів 😉

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.