У нас есть традиция – каждой весной мы участвуем в Днях карьеры любимого Новосибирского госуниверситета, главной кузницы наших кадров. И каждый год мы придумываем для студентов что-нибудь любопытное. В этом году сделали мастер-класс о том, как написать чат-бота. Для регистрации на мастер-класс запустили в Telegram собственного бота «Академик» @academic_quiz_bot. Его же все вместе и собирали на мастер-классе.
Если вы еще не завели себе симпатичного бота, сейчас расскажем, как выбирать тему, и, собственно, делать бота.
Шаг 1. Чат-бот и Дни карьеры, где связь?
Придумать интересную тему для мастер-класса было непросто, ведь конкурировать приходится уже и с самими собой. 2 года назад мы за полтора часа мастер-класса на глазах студентов написали мобильное приложение для айфона, которое показывает погоду около их альма-матер НГУ (более 5000 загрузок, и продолжает скачиваться). Год назад мы за 1,5 часа сделали IoT-решение и научились зажигать лампочку со смартфона. В этом году нужно было сделать что-то не менее зажигательное, и мы решили научить студентов делать чат-ботов, ведь эти виртуальные собеседники сейчас переживают взрывной рост популярности.
Идея делать чат-бот традиционно взялась у нас не с потолка – для одного из наших заказчиков, мы как раз разработали чат-бот в Telegram, который позволяет территориальным менеджерам компании без доступа к стационарному компьютеру и быстрому интернету, оперативно управлять проектами – назначать, делегировать задачи и следить за статусом их исполнения.
К тому же, бот был удобным способом в игровой форме собрать контакты студентов и пригласить на мастер-класс.
Шаг 2. О чем будем чатиться?
Бота мы обязательно хотели сделать полезным, чтобы он продолжал жить и радовать всех и после Дней карьеры. После долгих раздумий и споров, такую тему нашли! В основу положили популярную в нашем городе онлайн-анкету Алексея Козионова на знание Новосибирского Академгородка. И за неделю до мастер-класса мы запустили в Telegram нашего чат-бота «Академик» @academic_quiz_bot.
«Академик» задает любопытные вопросы (всего 101 вопрос) с вариантами ответов о жизни любимого района, вроде: «Что в Академгородке называют "Поганкой"?», «Какой американский президент посещал Академгородок?», «Почему микрорайон называется "Щ"?», «Сколько ступенек имеет лестница, ведущая от центрального пляжа до моста?», «Кто из лауреатов Нобелевской премии жил в Академгородке?» и так далее.
«Академика» мы дополнили стикерпаком с подбадривающими фразочками от нашего талисмана – ebtman’а.
Скачать стикерпак можно тут.
В первую неделю жизни бот также работал регистратором на мастер-класс и тщательно собирал контакты участников. А сейчас это просто развлекательный тест. Все, кто знает Академгородок, вэлкам проходить викторину.
Шаг. 3. Рецепт чат-бота викторины
Про создание чат бота с помощью Microsoft Bot Framework на Хабре писалось уже много. А вот туториалов – единицы. Поэтому для всех, кто ещё не запилил себе виртуального «болтуна», рассказываем наш рецепт.
Для начала создаём проект в Visual Studio с использованием Bot Application Template.
После создания мы сразу получаем готового «Эхо» бота, который повторяет отправленное ему сообщение в ответе. Мы можем запустить проект кнопочкой F5. В браузере откроется дефолтная страница вот такого вида:
Копируем адрес и запускаем эмулятор.
Куда вставляем скопированный адрес и дописываем api/messages. Проверяем что всё работает.
Далее усовершенствуем нашего бота. Заставим задавать его вопросы и проверять ответы.
Для начала нам понадобятся данные для вопросов. Можно сделать это разными способами. Можно хранить локально или загружать из облака. Мы выбрали второй вариант.
Создадим простой класс для загрузки вопросов.
public class QuestService
{
private static QuestService _instance;
public static QuestService Instance => _instance ?? (_instance = new QuestService())
private static string _url =
"https://academicquizbot.blob.core.windows.net/content/questions.txt";
private readonly List<Question> _questions;
public int QuestionsCount => _questions.Count;
public QuestService()
{
var root = DoRequest<QuestionRoot>(_url);
_questions = root.Questions;
for (int i = 0; i < _questions.Count; i++)
{
_questions[i].Index = i;
}
}
private T DoRequest<T>(string requestStr) where T : class
{
var req = WebRequest.Create(new Uri(requestStr));
req.Method = "GET";
var response = req.GetResponse();
var stream = response.GetResponseStream();
if (stream == null)
{
return null;
}
using (var rstream = new StreamReader(stream))
{
var stringResult = rstream.ReadToEnd();
var result = JsonConvert.DeserializeObject<T>(stringResult);
return result;
}
}
public Question GetQuestion(List<int> exceptIndexes)
{
var questions = _questions.Select(q => q)
.Where(q => !exceptIndexes.Contains(q.Index)).ToList();
if (questions.Count == 0)
{
return null;
}
Random rand = new Random();
return questions[rand.Next(questions.Count)];
}
}
Этот класс при создании будет загружать вопросы с сервера и при необходимости возвращать следующий вопрос исключая те, на которые пользователь уже ответил.
Создадим новый класс в Visual Studio под названием QuizDialog. И реализуем в нём интерфейс IDialog<string>.
[Serializable]
public class QuizDialog : IDialog<string>
{
async Task IDialog<string>.StartAsync(IDialogContext context)
{
context.Wait<string>(MessageReceived);
}
private async Task MessageReceived(IDialogContext context, IAwaitable<string> message)
{
await ShowQuestion(context);
}
private async Task ShowQuestion(IDialogContext context)
{
}
}
Реализуем функцию ShowQuestion. Сначала загрузим список уже пройденных вопросов из хранилища.
var history = DataProviderService.Instance.GetAnswerStatistic(context.Activity.From.Id);
И запросим следующий вопрос
var exceptIndexes = history.Select(h => h.Index).ToList();
_question = QuestService.Instance.GetQuestion(exceptIndexes);
if (_question == null)
{
context.Done("");
return;
}
Если вопросов нет, то считаем квиз пройден и возвращаем управление в родительский диалог.
Далее отправляем текст вопроса пользователю.
StringBuilder questionText = new StringBuilder();
questionText.AppendLine(BoldString($"Вопрос №{history.Count + 1}:"));
questionText.AppendLine(_question.Text);
questionText.AppendLine(_question.ImageUrl);
var reply = context.MakeMessage();
reply.Text = questionText.ToString();
reply.TextFormat = "xml";
await context.PostAsync(reply);
Также картинки можно передавать не через url (хотя я считаю, что в telegram надёжнее именно так), а через Attachment.
if (!string.IsNullOrWhiteSpace(_question.ImageUrl))
{
reply.Attachments.Add(new Attachment("image/jpg", _question.ImageUrl));
}
Код функции BoldString:
private string BoldString(string text)
{
if (string.IsNullOrEmpty(text))
{
return text;
}
return $"<b>{text}</b>";
}
Текст форматируем в «xml» чтобы разделить текст по строчкам и выделить строчку с номеров вопроса жирным цветом. Тут стоит учитывать, что не все мессенджеры поддерживают одинаковые форматы, тут стоит читать документацию.
Далее нам нужно вывести список ответов. Будем выводить двумя способами. Так как у нас бывают ответы с длинным текстом. Такие мы будем выводить просто под номерами и кнопочки с номерами ответов после. А если ответы короткие (до 30 символов), то будем выводить их сразу в виде кнопочек.
bool isLongAnswer = _question.Answers.Any(a => a.Length > LongTextLength);
var answers = _question.Answers;
if (isLongAnswer)
{
var answersReply = context.MakeMessage();
StringBuilder text = new StringBuilder();
answers = new List<string>();
for (int i = 0; i < _question.Answers.Count; i++)
{
text.AppendLine($"{i+1}) {_question.Answers[i]}");
answers.Add($"{i + 1}");
}
answersReply.Text = text.ToString();
answersReply.TextFormat = "xml";
await context.PostAsync(answersReply);
}
ResumeAfter<string> answerRecived = AnswerReceived;
if (isLongAnswer)
{
answerRecived = LongAnswerReceived;
}
PromptDialog.Choice(context, answerRecived,
new PromptOptions<string>(
prompt: "Выберите один из вариантов. \n /exit - для отмены",
retry: null,
tooManyAttempts: "Неверно",
options: answers,
attempts: 0,
promptStyler: new PromptStyler(),
descriptions: answers));
Собственно, функция PromptDialog.Choice показывает диалог с кнопочками. Далее нам понадобятся две функции для обработки выбора пользователя.
private async Task AnswerReceived(IDialogContext context, IAwaitable<string> message)
{
bool isCorrect = false;
try
{
var answer = await message;
isCorrect = answer == _question.CorrectAnswer;
}
catch
{
// ignored
}
await ShowResult(context, isCorrect);
}
private async Task LongAnswerReceived(IDialogContext context, IAwaitable<string> message)
{
bool isCorrect = false;
try
{
var answer = await message;
var answerNumber = int.Parse(answer) - 1;
isCorrect = _question.Answers[answerNumber] == _question.CorrectAnswer;
}
catch
{
// ignored
}
await ShowResult(context, isCorrect);
}
В случае если пользователь написал что-то некорректное считаем его ответ неправильным. Вот такие мы злодеи.
Далее, нужно показать правильный ответ и статистику. И небольшим бонусов если пользователь общается с ботом через «telegram» подбодрить его стикером.
private async Task ShowResult(IDialogContext context, bool isCorrect)
{
string isCorrectStr;
Random rand = new Random();
string sticker = "";
if (isCorrect)
{
var index = rand.Next(Consts.CorrectAnswers.Length);
isCorrectStr = Consts.CorrectAnswers[index];
var stickerIndex = rand.Next(Consts.GoodStickers.Length);
sticker = Consts.GoodStickers[stickerIndex];
}
else
{
var index = rand.Next(Consts.BadAnswers.Length);
isCorrectStr = Consts.BadAnswers[index];
var stickerIndex = rand.Next(Consts.BadStickers.Length);
sticker = Consts.BadStickers[stickerIndex];
}
bool isTelegram = context.Activity.ChannelId == "telegram";
if (isTelegram && rand.Next(100) > 30)
{
var bot = new TelegramBotClient(Consts.Telegram.Token);
await bot.SendStickerAsync(context.Activity.From.Id, sticker);
}
var reply = context.MakeMessage();
reply.Text = BoldString($"{isCorrectStr}");
reply.TextFormat = "xml";
await context.PostAsync(reply);
var answer = new AnswerStatistic { Index = _question.Index, IsCorrect = isCorrect };
DataProviderService.Instance.AddAnswerStatistics(context.Activity.From.Id, answer);
await ShowAnswerDescribe(context);
await ShowStatistic(context);
await ShowQuestion(context);
}
private async Task ShowAnswerDescribe(IDialogContext context)
{
var reply = context.MakeMessage();
reply.Text = _question.DescribeAnswer + _question.DescribeAnswerImageUrl;
await context.PostAsync(reply);
}
private async Task ShowStatistic(IDialogContext context)
{
var user = DataProviderService.Instance.GetUser(context.Activity.From.Id);
var history = DataProviderService.Instance.GetAnswerStatistic(context.Activity.From.Id);
var reply = context.MakeMessage();
int correctCount = history.Count(h => h.IsCorrect);
int questionsCount = QuestService.Instance.QuestionsCount;
string text = $"{history.Count}/{questionsCount} Из них верно: {correctCount}";
reply.Text = text;
await context.PostAsync(reply);
}
Далее в MessagesController нужно вызвать наш диалог.
public async Task<HttpResponseMessage> Post([FromBody]Activity activity)
{
if (activity.Type == ActivityTypes.Message)
{
await Conversation.SendAsync(activity, () => new QuizDialog());
}
else
{
HandleSystemMessage(activity);
}
var response = Request.CreateResponse(HttpStatusCode.OK);
return response;
}
После отладки в эмуляторе можно опубликовать бота. Вот тут это уже описано.
А наш проект целиком лежит вот тут.
Шаг 4. Подводим итоги
Этой студенческой весной мы научили молодежь писать собственных чат-ботов и собрали рекордное для нас количество участников на мастер-классе. Снова повстречались с фанатами ? У нас есть ребята, в смысле парень и девушка, которые ходят к нам на мастер-классы уже третий год подряд и, наконец, доросли, до 4 курса. Говорят, напишут диплом и сразу к нам (мы раньше 4-го курса студентов все равно не берем).
А еще, сами получили большое удовольствие от общения с молодежью и вдохновение для новых проектов! Ну и викторину нашу очень любим и рекомендуем всем, кто знаком с Академом — @academic_quiz_bot.