Как мы за 24 часа построили конструктор Telegram-ботов, а потом половину выкинули и переписали +14


Все началось 20.06, когда я увидел в твиттере популярного блогера Варламов этот твит:

image
В тот же момент я подумал: ведь мессенджер вообще и Telegram в частности это идеальный способ взаимодействия с клиентом. Зачем нам приложение, чтобы доставлять последние новости если это можно просто прислать их в чат?

Зачем вам приложение для заказа такси, когда вы можете написать в чат любимому оператору такси «хочу такси в Домодедово от м. Южное через 35 минут» — и такси заказано. Зачем вам приложение для заказа из кафе, когда можно написать в чат «хочу двойной эспрессо и бейгл с осетром» — и осталось только послать свой адрес. Таких примеров использования чата может быть огромное множество.

В тот же день я написал небольшой пост в клубном новостном сообществе TJournal, где предложил в рамках предстоящего хакатона AngelHack написать продукт и создать демонстрационного бота: для подписки на новости и уведомления из этого сообщества. Через четыре дня Павел Дуров официально запустил поддержку ботов, а еще через две недели мы победили на AngelHack в номинации от IBM с проектом Leecero. Под катом большая история…



Пролог: Что же мы такого накодили?


Leecero это продукт который позволяет легко создавать бота Телеграм с помощью сценария, представленного в виде конечного автомата, поддержке естественного языка и сторонних REST API. В двух словах: конструктор ботов.

Вот пример сценария простого бота который отправляет по запросу курсы валют:



Leecero — это web-приложение построенное на базе ASP.NET MVC Web API, MSSQL и развернутое в Azure. Изначально мы планировали построить его, и построили, на базе Azure Storage Queue и WebJobs. В этом мы ужасно ошиблись, почему — расскажу позже.

В рамках хакатона мы не только разработали продукт, но и несколько демонстрационных ботов: валютного бота, новостного бота, бота демонстрирующего заказ еды из кафе и бота демонстрирующего клиентскую поддержку банка.


Все боты были написаны на разных ЯП: у нас был питон, серверный JS, C# — общение с сервером происходит посредством REST API, а благодаря IBM Bluemix захостить их не составляло проблемы. По своей сути, «боты» — это просто набор конечных точек, которые вызываются в нужный момент из Leecero. Сам «бот», его сценарий находится на сервере.

Один бот был вообще без какого либо кода, только сценарий: это бот симулирующий банковскую поддержку:



Акт 1: Ах, какие планы


К хакатонам надо готовится. Как минимум с используемыми технологиями проблем быть не должно. Конечно, в рамках хакатона можно попробовать изучить что-то принципиально новое, но лучше не стоит — обидно будет проиграть в борьбе с криво подключенной библиотекой. В тот момент я работал с ASP.NET WebAPI, а также Azure Storage — их я и решил использовать для создания Leecero. Я спроектировал ее таким образом:



Идея была в том, что если нагрузка на сервис вырастает, часть сообщений, например от крупного клиента, выделяется в отдельный webjob и обрабатывается независимой очередью — этакая очевидная балансировка нагрузки, как мы думали. К тому же webjobs бесплатные.

Сначала надо было написать или найти готовый клиент Telegram Bot Api. К счастью, в прошлом году я участвовал в конкурсе Павла Дурова на создание Telegram на Android. Я использовал Xamarin и C#, поэтому у меня остались некоторые наработки. Было довольно просто превратить их в клиентскую библиотеку для Telegram Bot Api. Её код я решил открыть под GPLv3: TelegramBot.Client

WebJob — это либо консольное приложение, которое запускается на сервере веб-приложения Azure по расписанию, либо функция, которая может вызываться по событию из Azure Storage. Нам был интересен второй вариант.

public static async Task ProcessMessage([QueueTrigger("telegram-messages")] TelegramMessage update,
            TextWriter logger, CancellationToken token)
        {
            if (update.Update.Message.Text == null)
                return;

            var client = new TelegramBotApiClient(update.Token);
            var message = update.Update.Message;

            await client.SendChatAction(message.Chat.Id, "typing");

            await ProcessTelegramMessage(update, client,  recognizeResult, logger);
        }


[QueueTrigger("telegram-messages")] — ключевой атрибут. Azure берет на себя большую часть работы, и когда в очереди telegram-messages появятся новые сообщения, то функция ProcessMessage будет вызвана и выполнена. Наша задача в дальнейшем лишь положить сообщение в очередь.

Сообщения от Telegram бота можно получать либо путем poll сервера, либо с помощью webhook. Для webhook требуется ссылка с https, т.е. с безопасным сертификатом. Причем самоподписанные сертификаты не подходят, нужен именно сертификат от удостоверяющего центра. К счастью *.azurewebsites.com защищен сертификатом поэтому webhook создается за пару минут:

        // POST: api/Message/token
        [Route(@"api/Message/{tokenf}/{tokens}")]
        public async Task<HttpResponseMessage> Post(string tokenf, string tokens, [FromBody]Update value)
        {
            var token = string.Concat(tokenf, ":", tokens);

            System.Diagnostics.Debug.WriteLine("Message from {0}: {1}", token, value.Message.Text);

            // Create a message and add it to the queue.
            var tm = new TelegramMessage
            {
                Token = token,
                Update = value
            };

            var message = new CloudQueueMessage(JsonConvert.SerializeObject(tm));
            await _queue.AddMessageAsync(message);            

            return new HttpResponseMessage(HttpStatusCode.OK);
        }

Я не буду вдаваться в подробности ASP.NET WebApi, скажу только что он очень мощен, гибок и удобен, но в то же время достаточно запутан — так как почти целиком строится на внутренних соглашениях о наименованиях, последовательности вызовов и т.п. Не зная их, можно довольно долго думать что у тебя не работает. Впрочем большую их часть можно переопределить. В данном случае мы создали POST метод, который принимает url вида leecero.com/api/Message/{tokenf}/{tokens}с телом Update, сериализованном в виде JSON. Или XML. Какой вид сообщения и как его десериализовать определит сам ASP.NET исходя из заголовков. А строчка await _queue.AddMessageAsync(message); положит наше сообщение в очередь для обработки.

Как видите, построить рабочий продукт с помощью Azure оказалось относительно просто. Я даже нашел время побродить по той небольшой части Mail.ru которая нам доступна и пофоткать, а так же поспать часик.



Самое веселье началось утром, когда мы дописали ботов и стали активно готовится к демонстрации. После продолжительного тестирования мы выяснили что боты довольно долго думают — тогда мы обвесили все стопвотчами пытаясь найти узкое месте.

2015-07-20T18:48:20  PID[3016] Verbose     ChatStateController.NextState - GetCurrentState: 00:00:00.9852497
2015-07-20T18:48:22  PID[3016] Verbose     ChatStateController.NextState - DbQuery: 00:00:01.2952876
2015-07-20T18:48:22  PID[3016] Verbose     CurrentState: Pretty, input: /start, class: Unknown
2015-07-20T18:48:22  PID[3016] Verbose     CurrentState after Pretty, action 
2015-07-20T18:48:22  PID[3016] Verbose     ChatStateController.NextState - Save state: 00:00:00.3723358
2015-07-20T18:48:22  PID[3016] Verbose     Process text message: 00:00:02.7199401
2015-07-20T18:48:22  PID[3016] Verbose     Processing 111111:TOKEN/C_ID with message /start
2015-07-20T18:48:22  PID[3016] Verbose     Action: Команда не найдена, Term: 
2015-07-20T18:48:22  PID[3016] Verbose     Send simple message without action: 00:00:00.1108038
2015-07-20T18:48:22  PID[3016] Verbose     Sucessfully sent message Команда не найдена from bot to C_ID

Сначала мы думали что Telegram продолжает быть под DDOS и поэтому все тормозит. Потом мы думали что причина в обращение к БД (там мы храним сценарий) или к Azure Table Storage (там key-value хранилище текущего состояния чата) — однако благодаря кешированию все запросы после первого выполнялись за доли секунды. В конце концов мы решили оставить попытки ускорить сервис, так как возникла новая проблема: web job, который был запущен в режиме continuous засыпал через какое-то время бездействия и на его пробуждение уходили минуты! Представьте что бы было если бы он заснул во время выступления.



В общем пришлось подгонять сервис, закидывая спамом ботов все время пока шло выступление — чтобы они не расслаблялись.

Интермедия — NLP, она же обработка естественного языка


Я выше пишу про поддержку естественного языка. И это действительно есть в Leecero — благодаря доступным публично NLP-as-Service. В России самым большим специалистом по естественным языкам я лично считаю Яндекс. Например, можно вспомнить давний проект Автоматическая Обработка Текста доступный по LGPL. Сегодня, как я понимаю, авторы работают в Яндексе. Но для хакатона подключение к ASP.NET приложению COM библиотеки слишком тяжелая задача.



Поэтому мой выбор пал на сервис Yandex SpeechKit Cloud. Многие слышали что он умеет распознавать голос, но многие ли знают что у него есть модуль выделения смысловых объектов из текста? Модуль умеет выявлять из введенной фразы дату и время (например — «завтра»), имя-фамилию и адреса — а значит это умеет делать и Leecero! Помимо этого, в него встроен стеммер и морфологический анализатор, который выделяет части предложения: глаголы, существительные и т.п. Одна проблема — для таких задач дороговат, 5$ за 1000 запросов. Поэтому вызов к SpeechKit Cloud это второй этап обработки фразы, сначала используется стеммер от ребят из Cтенфорда, благо он доступен по GPLv2.

Однако остался не покрыт английский язык! На сегодня SpeechKit Cloud поддерживает русский, украинский, турецкий. Поэтому для разбора англоязычных фраз мы подключили AlchemyApi от IBM. AlchemyApi это компания которая делала natural language processing с помощью machine learning с 2009 года. Жила себе спокойно, а в 2015 была куплена IBM, интегрирована в Watson и стала частью решения IBM — IBM Bluemix. И что самое прекрасное — доступно наряду с другими сервисами Watson через публичное API


Возможности AlchemyApi просто огромные. Конечно 24 часов на хакатоне не хватит чтобы изучить их всех. Вот небольшой пример который мы успели внедрить — выделение из фразы заказа ключевых сущностей: собственно тех продуктов, которые и надо заказать.



Как можно увидеть, сервис разбирает предложение, выделяет из него ключевые слова и отдает их список в виде xml. Этого достаточно чтобы найти по этим ключевым словам продукты в БД и сформировать заказ. Однако, я заметил что AlchemyApi лучше работает на больших текстах, чем на коротких фразах. Но, как я понимаю, это все вопросы обучения — вопросы которые явно за рамками этой, и так затянувшейся, статьи.

Использование AlchemyApi в программе очень простое — у них есть бета версия C# api v 0.10, по факту там обычный REST.
Использование тривиальное
// Create an AlchemyAPI object.
AlchemyAPI.AlchemyAPI alchemyObj = new AlchemyAPI.AlchemyAPI();

// Load an API key 
alchemyObj.SetAPIKey("KEY");

string order = "I want to order one Margarita Pizza, two small colas and Ham Pizza";

var xml = alchemyObj.TextGetRankedNamedEntities(order);
System.Diagnostics.Debug.WriteLine(xml);

var xml2 = alchemyObj.TextGetRankedKeywords(order);
System.Diagnostics.Debug.WriteLine(xml2);

На выходе ключевые слова из фразы
<?xml version="1.0" encoding="UTF-8"?>
<results>
    <status>OK</status>
    <usage>By accessing AlchemyAPI or using information generated by AlchemyAPI, you are agreeing to be bound by the AlchemyAPI Terms of Use: http://www.alchemyapi.com/company/terms.html</usage>
    <totalTransactions>1</totalTransactions>
    <language>english</language>
    <keywords>
        <keyword>
            <relevance>0.926847</relevance>
            <text>Margarita Pizza</text>
        </keyword>
        <keyword>
            <relevance>0.820088</relevance>
            <text>Ham Pizza</text>
        </keyword>
        <keyword>
            <relevance>0.642818</relevance>
            <text>small colas</text>
        </keyword>
    </keywords>
</results>


Акт 2: Один день после


Порадовавшись победе и отоспавшись немного я заглянул в код который мы понаписали. Во-первых я сразу нашел причину тормозов ботов — оказывается WebJob работает с очередью методом polling'a с минимальным интервалом до 2сек. Т.е. меньше интервал поставить нельзя.

Интервал устанавливается при создании работы:

       static void Main(string[] args)
        {
            // HACK: заменить CS на параметры
            JobHostConfiguration configuration = new JobHostConfiguration("cs");
            configuration.Queues.MaxPollingInterval = TimeSpan.FromSeconds(2);

            var host = new JobHost(configuration);           
            host.RunAndBlock();
        }

Вторая проблема (засыпание WebJob) решалась установкой галки в настройках web-сервиса.



Возможно. Я не проверял. Когда я это понял я выкинул все что связано с WebJob и Azure Queue и переписал все на старом-добром TPL Dataflow. А еще я сразу добавил DI-контейнер и зарефакторил половину сервисов. В общем мой персональный хакатон продлился на одну ночь больше.

TPL.Dataflow — это библиотека разработанная для реализации шаблона проектирования Data Flow или конвейера обработки.

В двух словах, библиотека позволяет построить конвейер состоящий из блоков хранения или обработки данных, связав их по какому-либо условию. Таким образом я строю конвейер, где на вход подается сообщение пользователя, а на выход — ответ согласно сценарию, который может включать внутри себя в т.ч. и вызовы стороннего web api. Dataflow дает мне простые возможно параллельного выполнения любых блоков, фильтрации и управления очередями. И в отличие от Azure Queue все в памяти и очень быстро. Как я понял, очереди Azure подходит только чтобы картинки конвертировать.

Начал я с очень простого конвейера:

            // setup TPL dataflow            
            _messageBuffer = new BufferBlock<TelegramMessage>();
            var messageProcessor = new ActionBlock<TelegramMessage>(async message =>
            {
                try
                {
                    await _processor.ProcessMessage(message);
                }
                catch (Exception ex)
                {
                    Container.GetInstance<ILogger>().Log(ex.ToString());
                }
            });
            _messageBuffer.LinkTo(messageProcessor);


Сообщение туда кладется напрямую из WebHook'a, примерно так же как это было с очередью Azure. Он показал себя довольно хорошо, однако я хочу расширить его, добавив несколько этапов обработки сообщения, включая: классификацию языка, морфологический анализ, вызов сторонних API функций, непосредственно ответ. Сейчас я проектирую эту схему, а заодно дописываю поддержку медиа-файлов и английскую морфологию.

Эпилог: Так что же в итоге получилось?


Я уверен, что множество людей найдут тысячи применений чатам и ботам Telegram. Уже сегодня Aviasales сделала бота для поиска авиабилетов, Meduza сделала бота который ищет новости с тегом /cat, а блогер Илья Варламов присылает уведомления о всех своих постах прямо в Telegram.




К сожалению, не доступен сервер mySQL