В начале лета у мессенджера Telegram, созданного командой основателя «Вконтакте» Павла Дурова, появился программный интерфейс для разработки ботов. Неплохой повод для экспериментов с диалоговыми интерфейсами!

 

Вступление

Что должен уметь хороший робот? Ответ на этот вопрос лежит на поверхности. Всякий знает, что по-настоящему хороший робот стремится убить всех людей и уничтожить мир. Но массовые убийства — это, скорее, программа-максимум. Мы к ним обязательно вернемся, но начинать будем с малого.

Минимальные требования к нашему роботу просты. Во-первых, ему придется делать что-то если и не полезное, то хотя бы реалистичное. Телеграммный аналог «Hello, World» нас не устроит хотя бы по той причине, что три строки кода трудно растянуть на целую статью. Во-вторых, он должен поддерживать естественное общение хотя бы на уровне Siri. Специальные команды, которые нужно разучивать, и прочее шаманство, напоминающее командную строку UNIX, не пройдет. В-третьих, его возможности не должны ограничиваться обменом текстовыми сообщениями. Telegram способен на большее, и это было бы неплохо показать.

 

Возможности

Возможности веб-фреймворков обычно демонстрируют на примере разработки блоговых движков. Мы разработаем нечто похожее по сути, но несколько более персональное: простенький трекер для любителей Quantified Self. Идея Quantified Self заключается в том, что сбор и анализ данных о самом себе помогает заметить тенденции и факты, которые невозможно различить невооруженным взглядом. Некоторые виды информации можно отслеживать автоматически при помощи датчиков в фитнес-браслетах, умных часах или смартфонах. Другие нужно собирать вручную. Существуют, к примеру, приложения для ведения дневника настроений или для хранения данных о съеденной еде (худеющим это полезно).

Ничто не мешает когда-нибудь интегрировать наш трекер с сервисами вроде Fitbit, но в данный момент мы ограничимся ручным сбором информации. Попробуем сделать мокап интерфейса — разумеется, текстовый. Наш воображаемый пользователь будет записывать в трекере результаты взвешивания на напольных весах, прочитанные книги и продолжительность сна. Как? Например, так.

Пользователь: Я вешу 100 кг.
Робот: Запомнил!
Пользователь: Прочитал «Войну и мир». 
Робот: Запомнил!
Пользователь: Спал шесть часов.
Робот: Запомнил!

Диалог звучит естественно? Еще как — сложно было бы сказать по-другому. В то же время в этих сообщениях можно разглядеть смысл и без искусственного интеллекта. Первое слово, если отбросить местоимение «я» — это всегда описание действие. Остаток сообщения — объект, на который это действие направлено. Если же в сообщении есть число, его можно считать количественной оценкой.

Информацию нужно не только собирать. Необходимы еще средства для того, чтобы извлекать и анализировать ее. Попробуем представить естественный диалог, который позволяет это сделать.

Пользователь: Что я прочитал?
Робот: «Войну и мир», «50 оттенков серого», «В поисках утраченного времени», «Капитал», «Сумерки».
Пользователь: Сколько я спал?
Робот: Спал 15 раз. Значения от 4 до 13, в среднем 6,3. Всего: 77.

Вырисовывается логика. Тут командой служит первое слово: «что» для истории действия, «сколько» для статистики. Второе слово — это название действия, которое в записи шло первым. «Я» и вопросительный знак в конце можно игнорировать. Очевидно, что такие вопросы сочетаются не с любым действием, но эту проблему легко решить, предусмотрев список синонимов.

 

Приступаем

Идея ясна, можно начинать. Зрители последней серии «Мстителей» знают, что Тони Старк делает роботов, задумчиво шевеля в воздухе цветными голограммами. Увы, нам далеко до Тони Старка. Мы вынуждены обходиться обыкновенным Python. Для хранения истории действий возьмем SQLite с единственной таблицей под названием memories.

create table memories (
  id integer primary key autoincrement,
  user_id integer not null,    -- идентификатор пользователя Telegram  
  predicate text not null,    -- действие
  object text not null,      -- что сделано
  num real,            -- значение, связанное со сделанным
  finished timestamp not null  -- время окончания действия
);

Теперь займемся описанной выше логикой. Выбрасываем из полученного сообщения «я» и вопросительный знак, делим его на две части, команду cmd и действие predicate, после чего решаем, что делать дальше.

if cmd in (u'что', u'кто', u'как', u'где', u'вспомни', u'действие'):
  return p_history(db, 0, predicate)
elif cmd in (u'сколько', u'посчитай'):
  return p_stats(db, 0, predicate)
else:
  predicate, num = cmd, extract_number(object)
    return  remember(db, 0, predicate, object, num)

Этот алгоритм отправляется в функцию process, которая принимает на входе базу данных и текст входящего сообщения, а на выходе отдает текст ответа робота. Функции p_history (история действия),p_stats (статистика по действию) иremember` (добавить новую запись) не заслуживают особого внимания: каждая из них сводится к простому запросу SQL.

def remember(db, user_id, predicate, object, num):
  db.execute(
    'INSERT INTO memories (user_id, predicate, object, num, finished) '
    'VALUES (?, ?, ?, ?, ?)',
    (user_id, predicate, object, num, datetime.datetime.utcnow())
  )
  db.commit()
  return u'Запомнил!'

Отладить диалог можно и в консоли — дедовском диалоговом интерфейсе.

with sqlite3.connect('tracker.db', detect_types=sqlite3.PARSE_DECLTYPES) as db:
  if len(sys.argv)>1:
    print process(db, ' '.join(sys.argv[1:]).decode('utf-8'))

Посмотрим, что получилось:

op$ python tracker.py я прочитал Войну и мир
Запомнил! 
op$ python tracker.py я вешу 87 кг
Запомнил!
op$ python tracker.py что я прочитал?
07/27/15: прочитал Сумерки
05/13/15: прочитал 50 оттенков серого
05/06/15: прочитал В поисках утраченного времени

Кажется, все работает. Получающийся диалог в достаточной степени похож на мокап. Можно переходить к следующей стадии: подключаться к Telegram.

 

Подключение

Большая часть программного интерфейса Telegram сводится к получению или отправке информации в формате JSON на специальные адреса на сервере мессенджера. В Python есть все необходимое для того, чтобы это делать, но кому охота возиться с JSON и HTTP-запросами вручную? За пару месяцев, которые миновали с момента появления API, на GitHub образовалось несколько неофициальных, но вполне адекватных библиотек, упрощающих взаимодействие с Telegram до детсадовского уровня. Мы будем использовать Python Telegram Bot.

pip install python-telegram-bot

Следующий пункт нашего пути — BotFather. Обычные сервисы раздают ключи для доступа к своим программным интерфейсам через веб. Telegram распространяет их через сам мессенджер. BotFather выделяет жаждущим программистам токены авторизации и настраивает свойства учетной записи новых ботов.

BotFather
BotFather

После ввода команды /newbot BotFather поинтересуется названием и именем нового бота, а затем пожалует адрес и токен, состоящий из 45 цифр и латинских букв. Этот токен понадобится для подключения к API.

import telegram
TELEGRAM_TOKEN = '235693616:AAGzgkTtvNkzivdXx6EfzQSdvGTm7eJI_M'
bot = telegram.Bot(TELEGRAM_TOKEN)

Для отправки текстовых сообщений служит метод sendMessage, но с ним есть одна тонкость. Чтобы отправить сообщение, бот должен знать идентификатор чата, в который она попадет: chat_id — это первый аргумент sendMessage. Поскольку создавать чаты может только человек, любое сообщение бота представляет собой ответ. Можно понять, почему так сделано. Если бы не это, с помощью ботов было бы слишком легко рассылать непрошеный спам.

 

Общение

Как узнать о том, что пользователь обращается к боту? Есть два способа. С одной стороны, можно нетерпеливо долбить серверы Telegram при помощи метода getUpdates, который возвращает все сообщения, пришедшие с момента прошлой проверки (или не возвращающего ничего, если к боту никто не обращался). С другой стороны, можно написать веб-приложение, которое умеет получать сообщения в виде JSON по POST. Если зарегистрировать его адрес в Telegram при помощи метода setWebhook, мессенджер будет сам передавать боту новые сообщения по мере их прибытия. Когда сообщений нет, бот может прохлаждаться, ничего не делая.

Понятно, что getUpdates — злодейский выбор. Сотни процессоров будут приближать климатическую катастрофу, круглые сутки гоняя через половину планеты тоскливый JSON, в котором сообщается, что полковнику никто не пишет. Ботам Telegram редко выпадает шанс убить всех людей и уничтожить мир. getUpdates — это как раз такой шанс, и лучше не будет. Тем не менее он настолько проще и приятнее, чем webhook, что выхода нет. Будем злодействовать.

Если вызвать getUpdates без аргументов, метод попытается вернуть все принятые сообщения от начала времен (или по крайней мере те из них, о которых Telegram все еще помнит). Чтобы обуздать его, нужно передать методу идентификатор обновления, полученный во время прошлого вызова. С поправкой на это цикл взаимодействия с ботом обретает такой вид.

with db:
  last_update_id = bot.getUpdates()[-1].update_id
  while True:
    for update in bot.getUpdates(offset=last_update_id):
      if last_update_id < update.update_id:
        if update.message.text:
          process(db, bot, update)
          last_update_id = update.update_id

Функции process придется пережить некоторые изменения. Если в первоначальной версии она получала текст сообщения пользователя, то теперь мы будем передавать ей обновление Telegram. Из него можно извлечь все необходимое: текст (msg), идентификатор чата (chat_id) и идентификатор пользователя (user_id).

msg = unicode(update.message.text)
chat_id = update.message.chat_id
user_id = update.message.from_user.id

Готовые ответы бота можно на месте переправлять в Telegram при помощи метода bot.sendMessage(chat_id, text). Важный момент: Telegram отказывается иметь дело с кодировками, отличающимися от UTF-8. Перед отправкой текст лучше конвертировать в UTF-8, иначе ошибки неизбежны.

if cmd==u'что' and object==u'делал': # список всех действий
  bot.sendMessage(chat_id, p_list(db, user_id).encode('utf-8'))
if cmd in (u'что', u'кто', u'как', u'где', u'вспомни', u'действие'):
  bot.sendMessage(chat_id, p_history(db, user_id, predicate).encode('utf-8'))
elif cmd in (u'сколько', u'посчитай'):
  bot.sendMessage(chat_id, p_stats(db, user_id, predicate).encode('utf-8'))
else:
  predicate, num = cmd, extract_number(object)
  bot.sendMessage(
    chat_id,
    remember(db, user_id, predicate, object, num).encode('utf-8')
  )

Обрати внимание, что user_id, который в консольной версии не использовался, теперь действует на всю катушку. Поскольку идентификатор пользователя учитывается при извлечении истории действий или статистики, каждый пользователь видит только свою информацию. Более сложному боту следовало бы завести отдельную таблицу для хранения информации о пользователях, но мы можем обойтись и без нее.

 

Проверка

Запускаем (это можно сделать даже на домашней машине — сервер не требуется), и можно обращаться к боту через Messenger. Самый простой способ найти его — нажать на адрес, который выдал BotFather (наш бот находится по адресу https://telegram.me/waste_of_time_bot). Диалог начинается с нажатия на кнопку «Пуск», которая отправляет боту сообщение /start. Это можно использовать: добавим в process проверку, которая замечает сообщение с таким текстом и в ответ объясняет пользователю, что делать дальше. Ту же самую подсказку можно применять и в качестве ответа на запросы о помощи.

elif cmd in (u'/start', u'/help', u'помощь'):
  bot.sendMessage(chat_id, HELP)
Первая встреча с ботом
Первая встреча с ботом

Возможности Telegram не ограничиваются текстом. Его боты могут принимать и отправлять среди прочего изображения, аудио, документы и видео. Это надо использовать. Пусть наш трекер прилагает к статистике действия красивую диаграмму. Какая статистика без диаграммы, верно?

Хотелось бы обойтись малой кровью и не тратить силы на вещи, которые не относятся напрямую к теме статьи. Рисование графиков свалим на Google: полузаброшенный сервис Google Charts готовит диаграммы в формате PNG, нарисованные по данным, которые содержатся в URL. Следующая функция составляет URL диаграммы Google Charts по списку пар (num, finished), извлеченных из таблицы memories.

def get_gchart(data):
  max_num = max(data, key=lambda m: m[0])[0]
  values, labels = zip(*[
    ('%d'%(100.0*num/max_num), date.strftime('%d.%m'))
    for num, date in data
  ])
  return 'http://chart.googleapis.com/chart?' \
    'cht=bvg&chs=600x200&chd=t:%s&chxl=0:|%s' \
    '&chxt=x,y&chxr=1,0,%d'%(','.join(values), '|'.join(labels), max_num)
Диаграмма, сгенерированная Google Charts
Диаграмма, сгенерированная Google Charts

Для отправки фотографий служит метод sendPhoto. Он очень похож на sendMessage, но вместо текста принимает либо идентификатор изображения на сервере Telegram, либо открытый файл, в котором содержится изображение, либо адрес изображения в интернете. Вариант с файлом нуждается в пояснении: из-за одной небольшой, но неприятной особенности Python Telegram Bot он должен быть именно файлом и ничем иным. Попытка отдать методу объект, который реализует интерфейс файла, но не является потомком класса file, закончится провалом. Это исключает использование, например, временных файлов, полученных при помощи tempfile, или cStringIO.

Мы с нашими гугловскими урлами можем игнорировать эти проблемы. У нас все просто:

memories = db.execute(
    'SELECT num, finished FROM memories '
    'WHERE user_id=? AND predicate=? 
    ORDER BY finished DESC LIMIT 40',
    (user_id, predicate)
  ).fetchall()
bot.sendPhoto(chat_id, get_gchart(memories))

Действует? Действует!

Диалог
Диалог
 

Управление

Еще одна интересная особенность Telegram — специальные клавиатуры с готовыми ответами, которые может показать бот. Ты уже видел их, когда общался с BotFather. Когда приходило время узнать, к чему именно относится наша просьба, он выкатывал кнопки с названиями всех наших ботов. Кнопка запуска в начале общения с ботом — еще один пример этой функциональности.

Наш робот при запуске будет показывать клавиатуру с двумя кнопками: одна из них запрашивает подсказку, а другая — отправляет команду «что я делал», которая выводит список упомянутых в базе данных действий.

Клавиатуру можно приложить к любому сообщению (в нашем случае она пойдет в нагрузку к сообщению с подсказкой), но сперва ее нужно приготовить. Для этого служит класс ReplyKeyboardMarkup.

custom_keyboard = [[ u'Что я делал', u"Помощь" ]]
reply_markup = telegram.ReplyKeyboardMarkup(custom_keyboard, resize_keyboard=True)
bot.sendMessage(chat_id, HELP, reply_markup=reply_markup)

Аргумент resize_keyboard заставляет клиент Telegram уменьшить кнопки до осмысленной величины (без него мобильная версия распахнет кнопки на четверть экрана).

Есть два способа сложить ненужную клавиатуру. Во-первых, можно сразу передать ReplyKeyboardMarkup аргумент one_time_keyboard, уведомляющий, что клавиатуру следует спрятать, как только она была использована. Во-вторых, можно отправить в качестве reply_markup результат работы telegram.ReplyKeyboardHide(). В нашем случае не требуется ни то ни другое — пускай эта клавиатура будет вечной.

Диалог
Диалог
 

Переходим в Web

Теперь, посмотрев, что нас ждет на темной стороне, выясним, чем плоха сторона добра. Не надо противиться эволюции!

За основу веб-приложения возьмем микрофреймворк Flask. Часть «микро» в данном случае означает, что самый простой сайт уместится в три строчки кода и не потребует десяти файлов и пятнадцати вложенных каталогов. Это именно наш случай: иметь пятнадцать файлов для такой малости как-то неловко, даже если все они автоматически сгенерированы при помощи специальной утилиты.

pip install flask

Тремя строчками, впрочем, тут не обойтись, база данных не велит. Нам потребуется функция, которая открывает базу данных при необходимости, и другая, чтобы закрыть ее, когда необходимость отпала. В настройках приложения придется указать название сервера, на котором установлено веб-приложение, — это нужно при его регистрации в Telegram.

import tracker # В этом модуле находится все, что мы обсуждали выше.
import telegram
from flask import Flask, g, request, url_for

app = Flask(__name__)
app.config['SERVER_NAME’]=‘bot_server.ru’

bot = telegram.Bot(tracker.TELEGRAM_TOKEN)

def get_db():
  db = getattr(g, '_database', None)
  if db is None:
    db = g._database = tracker.connect_db()
  return db

@app.teardown_appcontext
def close_connection(exception):
  db = getattr(g, '_database', None)
  if db is not None:
    db.close()

Дальше легче. Фактически можно обойтись двумя адресами. Адрес /receive будет принимать обновления Telegram, переводить JSON в объекты python-telegram-bot путем вызова telegram.Update.de_json, а затем отправлять добытое уже известной нам функции process. Это главное.

@app.route('/receive', methods=['POST'])
def receive_update():
  if request.method == "POST":
    update = telegram.Update.de_json(request.get_json(force=True))
    tracker.process(get_db(), bot, update)
  return 'ok'

Другой адрес потребуется лишь однажды. При открытии он регистрирует адрес /receive в Telegram при помощи метода setWebhook.

@app.route('/setup', methods=['GET', 'POST'])
def set_webhook():
  webhook_url = url_for('receive_update', _external=True)
  return 'ok' if bot.setWebhook(webhook_url) else 'failure'
 

Итоги

И это все? Увы, на самом деле страдания лишь начинаются! Telegram отказывается иметь дело с ботами, которые обитают на серверах, не поддерживающих защищенное соединение. И не просто защищенное — самодельный сертификат SSL не сгодится, нужно приобретать настоящий. Разумеется, в этом нет ничего невозможного (и даже по большому счету трудного), но при отсутствии практики этот процесс рискует занять куда больше времени, чем написание самого бота, не говоря уже о средствах.

В конце у тебя будет либо готовый робот, либо желание убить всех людей и уничтожить мир. Хмм. Можно считать, что ситуация беспроигрышная.

5 комментариев

  1. Аватар

    zabr

    12.09.2015 в 02:42

    а можно ссылку на исходники?

  2. Аватар

    ns3777k

    20.09.2015 в 00:21

    сейчас уже судя по доке можно самопальные сертификаты юзать для вебхуков (https://core.telegram.org/bots/api):
    To use a self-signed certificate, you need to upload your public key certificate using certificate parameter. Please upload as InputFile, sending a String will not work.

  3. Аватар

    Legich

    15.10.2015 в 11:59

    Вообще не понял, почему getUpdates — проще и приятнее, чем webhook?
    Вебхук дергает скрипт на вашем сервере, каждый раз когда обращается клиент.
    Нет обращений, нет вызовов. А в случае с getUpdates нужен Cron, указывать сдвиг и т.д.
    С self-signed сертификатами теперь вообще не вопрос.

  4. Аватар

    the_Mask

    13.01.2019 в 22:41

    А можно посмотреть на исходник?

  5. Аватар

    the_Mask

    14.01.2019 в 00:05

    А что за функция extract_number()?

Оставить мнение