Как создать бота-интервьюера, который поможет подготовиться к собеседованию

В пошаговой инструкции покажем, как разработать бота для подготовки к собеседованиям на фронтенд-разработчика. Он будет задавать вопросы по HTML, CSS, JS и React. При этом часть из них будет с вариантами ответа, а часть — без. Базу вопросов вы сможете пополнять самостоятельно.

Навигация по тексту:

Это длинный и подробный текст, с помощью которого вы научитесь работать с Telegram Bot API с помощью grammY и Node.js, а также — самостоятельно деплоить ботов на сервер.

О том, как создавать ботов в Telegram под другие задачи, читайте в нашем новом бесплатном курсе.

Регистрация бота и настройка проекта

Получение токена Telegram Bot API

Для начала нам нужно создать своего бота, а также получить его уникальный ключ — токен. Начинаем диалог с @BotFather в Telegram, вводим команду /newbot и настраиваем бота. После вы получите сообщение с уникальным токеном — он нам понадобится для работы с Telegram Bot API.

<i>@BotFather, профиль в Telegram.</i>
@BotFather, профиль в Telegram.

Подготовка рабочего окружения

Далее понадобится установить Node.js и npm. Как это сделать — описано в официальной документации. Проверить наличие пакетов в системе можно с помощью следующих команд:

node -v npm -v

Если в ответ на эти команды вы видите числовые значения, значит, все установлено корректно.

Теперь создадим директорию, в которой будем разрабатывать проект (у меня это frontend-interview-prep-bot), и инициализируем его с помощью npm:

npm init

В командной строке система задаст ряд вопросов про создаваемый пакет. Я оставил все поля, кроме имени, по умолчанию. Вы можете их заполнить или отредактировать позже.

<i>Инициализация пакета.</i>
Инициализация пакета.

После финального вопроса нажимаем Enter — в папке должен появиться файл package.json с ранее введенной информацией.

Теперь нам нужно подключить необходимые библиотеки. Главная — та, что позволит упростить разработку и удобно общаться с сервером Telegram. Здесь есть несколько популярных решений:

  • node-telegram-bot-api — самая старая и популярная библиотека. По ней вы найдете множество туториалов, она довольно многословна, но проста и понятна.
  • Telegraf — следующая по популярности, сложнее в использовании.
  • grammY — более лаконичная и простая библиотека в отличие от Telegraf, но сложная в сравнении с node-telegram-bot-api.
<i>Сравнение grammy, node-telegram-bot-api и telegraf в тренде.</i>
Сравнение grammy, node-telegram-bot-api и telegraf в тренде.

В качестве главной библиотеки мы будем использовать grammY. Кроме того, нам понадобится хранить свой токен в переменных окружения — для этого воспользуемся библиотекой dotenv.

Установим библиотеки с помощью команды:

npm i grammy dotenv

После в папке появится файл package-lock.json с подробным описанием библиотек и их зависимостей, папка node_modules с самими зависимостями. А также в package.json будут добавлены версии библиотек в поле dependencies.

Осталось только создать файл index.js, в котором будем писать код бота. Если решите использовать другое название, не забудьте также внести его в package.json.

Автоматизация перезапуска бота

Чтобы во время разработки бота не приходилось каждый раз перезапускать приложение, мы можем настроить автоматическое обновление при сохранении файлов. Это можно сделать с помощью пакета nodemon:

npm i nodemon

Переходим в package.json и добавляем команду запуска в свойстве scripts:

{ "name": "frontend-interview-prep-bot", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "start": "nodemon index.js" }, "author": "Arseniy Pomazkov", "license": "ISC", "dependencies": { "dotenv": "^16.3.1", "grammy": "^1.18.1", "nodemon": "^3.0.1" } }

На этом этапе структура проекта и рабочая среда готова, переходим к написанию кода.

Разработка бота

Основная структура

Открываем index.js, инициализируем объект Bot, используя API-токен, и запускаем бота:

// Обращаемся к библиотеке grammy и импортируем класс Bot const { Bot } = require('grammy'); // Создаем бота на основе импортированного класса, передавая // в качестве аргумента строку с уникальным токеном, который // получили ранее в BotFather const bot = new Bot('1234567890:UIEaeSx_YsRXdD-C39M0t1PzcdnZZ4HgsKq'); // Запускаем бота bot.start();

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

Создадим отдельный файл .env в корне проекта и переместим токен туда, записав в переменную BOT_API_KEY:

// Файл .env // Обратите внимание, что кавычек нигде нет BOT_API_KEY=1234567890:UIEaeSx_YsRXdD-C39M0t1PzcdnZZ4HgsKq

Теперь в файле index.js надо импортировать библиотеку dotenv, и токен будет доступен по вызову process.env.BOT_API_KEY:

require('dotenv').config(); const { Bot } = require('grammy'); const bot = new Bot(process.env.BOT_API_KEY); bot.start();

Для запуска бота нужно в консоли выполнить команду:

node index.js

Все работает, но смысла в этом мало, ведь мы пока не настроили поведение бота. Поэтому реализуем базовые сценарии:

  • реагирование бота на команду /start,
  • ответ на пользовательские сообщения,
  • обработку ошибок.

Команда /start

Чтобы реагировать на команды пользователя (/start, /help и другие), нужно настроить прослушивание события «команда» с помощью метода command. Первым аргументом он принимает название команды без слеша, а вторым — колбэк с реакцией.

Колбэк получает первым аргументом контекст — это объект, который включает информацию о чате, пользователе, отправленном сообщении и т. д. Чтобы ответить на команду, боту достаточно вызвать метод reply у полученного контекста и передать в него строку с ответом.

Поскольку отправка сообщения — это асинхронная операция, нам необходимо дождаться ее выполнения через await, сделав колбэк-функцию асинхронной:

bot.command('start', async (ctx) => { await ctx.reply( 'Привет! Я - Frontend Interview Prep Bot 🤖 \nЯ помогу тебе подготовиться к интервью по фронтенду', ); });

Давайте запустим бота и попробуем отправить ему команду /start:

Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Отлично, все работает — реализуем реагирование на события.

Реагирование на события

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

Добавим пока реагирование на сообщение «HTML» — в ответ на него будем отправлять вопрос по теме. Кроме того, добавим простой обработчик ошибок:

bot.hears('HTML', async (ctx) => { await ctx.reply('Какой тег используется для создания ссылки?'); });
bot.catch((err) => { const ctx = err.ctx; console.error(`Error while handling update ${ctx.update.update_id}:`); const e = err.error; if (e instanceof GrammyError) { console.error('Error in request:', e.description); } else if (e instanceof HttpError) { console.error('Could not contact Telegram:', e); } else { console.error('Unknown error:', e); } });

Подробнее об ошибках и их типах можно почитать по ссылке. Сейчас останавливаться на этом не будем — проверим бота на практике:

Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Работает. Давайте доработаем ответ на команду /start: кроме сообщения будем выводить кастомную клавиатуру с темами для вопросов.

Основная клавиатура

Добавляем класс Keyboard в импорт из grammy, инициализируем клавиатуру:

const { Bot, Keyboard } = require('grammy');
bot.command('start', async (ctx) => { const startKeyboard = new Keyboard() .text('HTML') .text('CSS') .row() .text('JavaScript') .text('React') .resized(); await ctx.reply( 'Привет! Я - Frontend Interview Prep Bot 🤖 \nЯ помогу тебе подготовиться к интервью по фронтенду', ); await ctx.reply('С чего начнем? Выбери тему вопроса в меню 👇', { reply_markup: startKeyboard, }); });

Вызовы метода text() добавляют новые кнопки, а обращение к методу row() — разделяет ряды кнопок. Чтобы кнопки были адекватного размера и не растягивались, в конце мы применяем операцию resized().

Клавиатуру сохраняем в переменную startKeyboard, которую после отправляем пользователю сообщением с помощью функции reply() и ее аргумента reply_markup.

Сохраним изменения и посмотрим, что получилось:

<i>Клавиатура с использованием метода resized() и без.</i>
Клавиатура с использованием метода resized() и без.

Обрабатывать нажатия по такой клавиатуре достаточно просто: когда пользователь нажимает на кнопку, происходит автоматическая отправка сообщения с текстом кнопки. Далее, используя bot.hears(), мы можем реагировать на сообщения с соответствующим текстом. Удобно, не так ли?

Обработка нескольких сообщений

Сейчас слушатель hears реагирует только на сообщение «HTML». Конечно, можно под каждую из тем создать отдельного слушателя, но в программировании следует соблюдать принцип DRY (Don’t Repeat Yourself) и стремиться к адекватной лаконичности.

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

bot.hears(['HTML', 'CSS', 'JavaScript', 'React'], async (ctx) => { await ctx.reply(`Сейчас задам вопрос по ${ctx.message.text}`); });
Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Inline-клавиатура

Прежде чем начнем работать над отправкой реальных вопросов, давайте рассмотрим второй тип клавиатуры — InlineKeyboard. Она создается почти как обычная, но будет появляться под самим сообщением. По аналогии добавляем InlineKeyboard в импорт и инициализируем объект:

const { Bot, Keyboard, InlineKeyboard } = require('grammy');
bot.hears(['HTML', 'CSS', 'JavaScript', 'React'], async (ctx) => { const inlineKeyboard = new InlineKeyboard() .text('Получить ответ', 'getAnswer') .text('Отменить', 'cancel'); await ctx.reply(`Что такое ${ctx.message.text}?`, { reply_markup: inlineKeyboard, }); });

Обратите внимание: для инициализации InlineKeyboard мы вызываем метод text(), но в этот раз передаем два аргумента — лейбл кнопки и payload data (некие данные). Первая часть будет видна пользователю на кнопках, а вторая нужна для реагирования на соответствующие нажатия, потому что на этот раз они не будут отправляться в качестве сообщений.

<i>Как выглядит InlineKeyboard в Telegram.</i>
Как выглядит InlineKeyboard в Telegram.

Чтобы обрабатывать нажатия на эти кнопки, нам потребуется добавить еще один слушатель, который будет реагировать на callback_query. Причем мы можем указать, что хотим реагировать только на те callback_query, у которых есть data:

bot.on('callback_query:data', async (ctx) => {})

Давайте при нажатии будем получать доступ до данных этого колбэка (в нашем случае это будут getAnswer и cancel) и выполнять соответствующие действия:

bot.on('callback_query:data', async (ctx) => { if (ctx.callbackQuery.data === 'cancel') { await ctx.reply('Отмена'); await ctx.answerCallbackQuery(); } });

Операция await ctx.answerCallbackQuery() нужна для того, чтобы Telegram перестал ждать ответа на этот запрос. Такое ожидание запускается автоматически каждый раз, когда пользователь нажимает на кнопку в inline-клавиатуре.

С отменой все просто, а вот с ответом сложнее. Нам, как минимум, нужно знать тему заданного вопроса, как максимум — весь вопрос (или его id), чтобы отправить нужный ответ.

Решим эту проблему так: будем передавать нужные данные прямо в data в callback_query. Но так как там принимаются только строки, а нам в будущем понадобится передавать более сложные структуры, можем сразу использовать объекты, переводя их в строки с помощью JSON.stringify:

bot.hears(['HTML', 'CSS', 'JavaScript', 'React'], async (ctx) => { const inlineKeyboard = new InlineKeyboard() .text( 'Получить ответ', JSON.stringify({ type: ctx.message.text, questionId: 1, }), ) .text('Отменить', 'cancel'); await ctx.reply(`Что такое ${ctx.message.text}?`, { reply_markup: inlineKeyboard, }); });

Теперь в обработчике callback_query мы можем узнать, какую тему выбрал пользователь:

bot.on('callback_query:data', async (ctx) => { if (ctx.callbackQuery.data === 'cancel') { await ctx.reply('Отмена'); await ctx.answerCallbackQuery(); return; } const callbackData = JSON.parse(ctx.callbackQuery.data); await ctx.reply(`${callbackData.type} – это составляющая фронтенда`); await ctx.answerCallbackQuery(); });

Обратите внимание: в конструкцию if добавлен return, чтобы завершать работу функции в случае выполнения условия. Такой подход называется Early Return Pattern.

Итоговый интерфейс.
Итоговый интерфейс.

Наборы вопросов и их обработка

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

{ "id": 1, "text": "Какой тег используется для создания ссылки?", "hasOptions": true, "options": [ { "id": 1, "text": "<link>", "isCorrect": false }, { "id": 2, "text": "<a>", "isCorrect": true }, { "id": 3, "text": "<href>", "isCorrect": false }, { "id": 4, "text": "<anchor>", "isCorrect": false } ] },

Вопрос с вариантами ответа.

{ "id": 6, "text": "Для чего используется атрибут 'placeholder'?", "hasOptions": false, "answer": "Атрибут 'placeholder' используется для отображения вводимого текста в поле формы до того, как пользователь начнет вводить свои данные. Это помогает предоставить подсказку или пример того, что должен ввести пользователь." },

Открытый вопрос.

Основной набор вопросов доступен в репозитории на GitHub.

Скачайте файл questions.json и добавьте его в корень проекта. В нем есть объект с четырьмя свойствами — html, css, javascript и react. По каждому из этих ключей хранится массив с вопросами.

Теперь там же, в корне, мы создадим файл utils.js – в него сразу вынесем утилитарную функцию для выбора рандомного вопроса по заданной теме. В utils.js сперва получаем доступ к нашему объекту с вопросами:

const questions = require('./questions.json');

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

Для получения рандомного индекса можно использовать классический подход: взять Math.random(), который генерирует число от 0 до 1, результат умножить на длину массива с вопросами и округлить до целого в меньшую сторону с помощью Math.floor():

const getRandomQuestion = (topic) => { const questionTopic = topic.toLowerCase(); const randomQuestionIndex = Math.floor( Math.random() * questions[questionTopic].length, ); return questions[questionTopic][randomQuestionIndex]; };

Но мы рассмотрим альтернативный способ. Для получения рандомного вопроса в npm можно использовать отдельную библиотеку — random-js. Чем она лучше подхода с Math — хорошо описано на странице репозитория.

Как обычно: устанавливаем новую зависимость, импортируем в utils.js класс Random и инициализируем новый объект:

npm i random-js
const { Random } = require('random-js'); … const random = new Random();

Далее вызываем метод integer, который первым аргументом принимает минимум, а вторым – максимум для генерации числа. Помним, что наш максимум на единицу меньше, чем длина массива. Получаем следующее:

const getRandomQuestion = (topic) => { const questionTopic = topic.toLowerCase(); const random = new Random(); const randomQuestionIndex = random.integer(0, questions[questionTopic].length - 1); return questions[questionTopic][randomQuestionIndex]; };

В конце файла utils.js экспортируем созданную функцию и импортируем ее в index.js.Теперь для получения рандомного вопроса нужно лишь вызвать созданную функцию, сохранить полученный вопрос в переменной и отправить пользователю текст этого вопроса.Кроме того, в questionId мы теперь можем передавать реальный идентификатор вопроса, чтобы в будущем легко найти на него ответ. Опцию Отменить уберем, она нужна была только для демонстрации создания нескольких кнопок:

bot.hears(['HTML', 'CSS', 'JavaScript', 'React'], async (ctx) => { const topic = ctx.message.text; const question = getRandomQuestion(topic); const inlineKeyboard = new InlineKeyboard() .text( 'Узнать ответ', JSON.stringify({ type: ctx.message.text, questionId: question.id, }), ) await ctx.reply(question.text, { reply_markup: inlineKeyboard, }); });
Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Отлично! Осталось для вопросов с вариантами ответа добавить соответствующие кнопки, которые нужно будет формировать динамически. Методом map будем проходиться по массиву вариантов и на каждый возвращать кнопку с помощью InlineKeyboard.text, а затем — собирать в Inline-клавиатуру:

const buttonRows = question.options.map((option) => [ InlineKeyboard.text( option.text, JSON.stringify({ type: `${topic}-option`, isCorrect: option.isCorrect, questionId: question.id, }), ), ]); const keyboard = InlineKeyboard.from(buttonRows);

При создании кнопки мы назначаем для нее тип <тема>-option, чтобы в обработчике callback_query отличать такие вопросы от вопросов без вариантов ответа. А также добавляем isCorrect с булевым значением.

Создание переменной keyboard вынесем над конструкцией if, а внутри вариантов уже будем назначать для нее нужную клавиатуру:

bot.hears(['HTML', 'CSS', 'JavaScript', 'React'], async (ctx) => { const topic = ctx.message.text; const question = getRandomQuestion(topic); let keyboard; if (question.hasOptions) { const buttonRows = question.options.map((option) => [ InlineKeyboard.text( option.text, JSON.stringify({ type: `${topic.toLowerCase()}-option`, isCorrect: option.isCorrect, questionId: question.id, }), ), ]); keyboard = InlineKeyboard.from(buttonRows); } else { keyboard = new InlineKeyboard().text( 'Узнать ответ', JSON.stringify({ type: ctx.message.text.toLowerCase(), questionId: question.id, }), ); } await ctx.reply(question.text, { reply_markup: keyboard, }); });
<i>Результат: вопрос с вариантами ответа.</i>
Результат: вопрос с вариантами ответа.

Теперь вернемся к нашему bot.on(‘callback_query:data’) и доделаем его. Удаляем все и пишем по новой. Начнем с того, что сразу будем парсить данные через JSON и сохранять результат в callbackData:

const callbackData = JSON.parse(ctx.callbackQuery.data);

Далее происходит деление на вопросы с вариантами ответа и без. Начнем с простого, когда вариантов нет. Программа будет это определять по отсутствию слова option в свойстве type:

if (!callbackData.type.includes('option')) {}

Если кнопка была нажата и вопрос без вариантов ответа, значит, пользователь кликнул по единственно возможной кнопке — Узнать ответ. Тут нам необходимо найти заданный вопрос по id и отправить пользователю ответ. Как в случае с поиском вопроса, для поиска ответа мы напишем функцию-хелпер в utils.js.

Перед реализацией функции опишем ее вызов и продумаем, что она должна принимать в качестве аргументов. Спойлер: это будут тип и id вопроса.

if (!callbackData.type.includes('option')) { await ctx.reply( getCorrectAnswer(callbackData.type, callbackData.questionId), ); await ctx.answerCallbackQuery(); return; }

Теперь добавим в отправку ответа пару дополнительных опций, чтобы форматировать текст:

await ctx.reply( getCorrectAnswer(callbackData.type, callbackData.questionId), { parse_mode: 'HTML', disable_web_page_preview: true }, );

Это нужно для того, чтобы корректно отображать ссылки на подробные объяснения, которые мы заботливо добавили в некоторые ответы на вопросы.

Следующим этапом отправимся в utils.js и напишем саму функцию:

const getCorrectAnswer = (topic, id) => { const question = questions[topic].find((question) => question.id === id); if (!question?.hasOptions) { return question.answer; } return question.options.find((option) => option.isCorrect).text; };

Сначала находим нужный вопрос с помощью перебора по типу и id. Если вариантов ответа нет, сразу возвращаем сообщение. А если есть — перебираем варианты и возвращаем ответ со значением isCorrect: true.

Добавим в экспорт новую функцию в том же файле и импортируем ее в index.js. Осталось вернуться в колбэк для обработки callback_query:data и дописать обработку вопросов с вариантами ответа. Случай, когда у вопроса вариантов нет, мы уже отсекли — значит, осталось только два сценария:

  • варианты ответа есть, ответ верный,
  • варианты ответа есть, ответ неверный.

Как раз для этого мы передавали в data значение isCorrect — теперь это позволит нам отбросить первый вариант:

if (callbackData.isCorrect) { await ctx.reply('Верно ✅'); await ctx.answerCallbackQuery(); return; }

Отправляем сообщение, завершаем ожидание колбэк-запроса и выходим из функции.

Теперь обработаем вариант с неверным ответом. Он также будет использовать функцию getCorrectAnswer, но при передаче типа необходимо будет отсечь часть -option. Это мы сделаем, разделив строку на два элемента и обратившись к первому:

await ctx.reply( `Неверно ❌ Правильный ответ: ${getCorrectAnswer( callbackData.type.split('-')[0], callbackData.questionId, )}`, ); await ctx.answerCallbackQuery();

Этот вариант будет без конструкции if, так как все остальные варианты развития событий мы уже отсекли.

Итоговый код index.js есть в репозитории на GitHub. В качестве самостоятельной работы добавьте к кнопкам выбора темы функцию Случайный вопрос. А также доработайте под нее обработчик bot.headers().

Деплой бота на сервер

Сейчас бот запущен на компьютере. Это неудобно, если нужно, чтобы он работал круглосуточно. Ведь тогда нужно поддерживать бесперебойную работу компьютера и постоянное соединение с интернетом. Бота лучше перенести в облако.

1. Переходим в раздел Облачная платформа внутри панели управления.

2. Создаем сервер. Для работы нашего приложения много мощностей не нужно, поэтому будет достаточно одного ядра vCPU с долей 20% и 512 МБ оперативной памяти.

Как создать бота-интервьюера, который поможет подготовиться к собеседованию

3. Авторизуемся на сервере через консоль.

Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Далее необходимо установить git и клонировать репозиторий из GitHub на сервер:

sudo apt install git
git clone <ссылка на ваш GitHub-репозиторий>

После предыдущей команды будет автоматически создана папка с названием репозитория (у меня это frontend-interview-prep-bot), переходим в нее, устанавливаем Node.js и пакет npm:

cd frontend-interview-prep-bot
sudo apt install nodejs
sudo apt install npm

Версии автоматически ставятся устарвшие, для их обновления выполним команды:

sudo npm install -g n sudo n stable

Далее — перезапустим сервер. Теперь снова перейдем в папку проекта и установим все зависимости:

cd frontend-interview-prep-bot npm i

Создадим файлик с ключом для нашего бота и вставим в него ключ от нашего бота:

touch .env
Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Последнее, что осталось сделать, — это запустить бота. Для этого будем использовать менеджер процессов pm2 и запустим бота (не забудьте перед этим убедиться, что остановили его локально, иначе возникнут конфликты)

npm i pm2 -g

pm2 start index.js

Вот такой результат говорит нам о том, что бот успешно запущен:

Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Все работает корректно, бот полностью функционирует без нашего участия:

Как создать бота-интервьюера, который поможет подготовиться к собеседованию

Что в итоге?

В результаты мы не только научились создавать Telegram-ботов на Node.js, обрабатывать команды и сообщения пользователя, реагировать на нажатия кнопок и колбэки, но и сделали себе отличного помощника в подготовке к собеседованию и закреплению материала.

Подпишитесь на блог Selectel, чтобы не пропустить новые обзоры книг, новости и кейсы из мира IT и технологий.

Читайте также:

33
1 комментарий