В этой статье мы разработаем бота для подготовки к собеседованиям. Он будет задавать вопросы по HTML, CSS, JS и React. При этом часть из них будет с вариантами ответа, а часть — без. Базу вопросов вы сможете пополнять самостоятельно.
Во время разработки вы научитесь работать с Telegram Bot API с помощью grammY и Node.js, а также самостоятельно деплоить ботов на сервер.
О чем пойдет речь:
→ Регистрация бота и настройка проекта
→ Разработка бота
→ Деплой бота на сервер
Регистрация бота и настройка проекта
Получение токена Telegram Bot API
Для начала нам нужно создать своего бота, а также получить его уникальный ключ — токен. Начинаем диалог с BotFather в Telegram, вводим команду
/newbot
и настраиваем бота. После вы получите сообщение с уникальным токеном — он нам понадобится для работы с Telegram Bot API.BotFather, профиль в Telegram.
Подготовка рабочего окружения
Далее понадобится установить Node.js и npm. Как это сделать — описано в официальной документации. Проверить наличие пакетов в системе можно с помощью следующих команд:
node -v
npm -v
Если в ответ на эти команды вы видите числовые значения, значит, все установлено корректно.
Теперь создадим директорию, в которой будем разрабатывать проект (у меня это frontend-interview-prep-bot), и инициализируем его с помощью npm:
npm init
В командной строке система задаст ряд вопросов про создаваемый пакет. Я оставил все поля, кроме имени, по умолчанию. Вы можете их заполнить или отредактировать позже.
Инициализация пакета.
После финального вопроса нажимаем Enter — в папке должен появиться файл package.json с ранее введенной информацией.
Теперь нам нужно подключить необходимые библиотеки. Главная — та, что позволит упростить разработку и удобно общаться с сервером Telegram. Здесь есть несколько популярных решений:
- node-telegram-bot-api — самая старая и популярная библиотека. По ней вы найдете множество туториалов, она довольно многословна, но проста и понятна.
- Telegraf — следующая по популярности, сложнее в использовании.
- grammY — более лаконичная и простая библиотека в отличие от Telegraf, но сложная в сравнении с node-telegram-bot-api.
Сравнение 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);
}
});
Подробнее об ошибках и их типах можно почитать в гайде grammY. Сейчас останавливаться на этом не будем — проверим бота на практике:
Работает. Давайте доработаем ответ на команду
/start
: кроме сообщения будем выводить кастомную клавиатуру с темами для вопросов.Основная клавиатура
Добавляем класс
Keyboard
в импорт из 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
.Сохраним изменения и посмотрим, что получилось:
Клавиатура с использованием метода 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 (некие данные). Первая часть будет видна пользователю на кнопках, а вторая нужна для реагирования на соответствующие нажатия, потому что на этот раз они не будут отправляться в качестве сообщений.Как выглядит 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,
});
});
Результат: вопрос с вариантами ответа.
Теперь вернемся к нашему
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().
Деплой бота на сервер
Сейчас бот запущен на компьютере. Это неудобно, если нужно, чтобы он работал круглосуточно. Ведь тогда нужно поддерживать бесперебойную работу компьютера и постоянное соединение с интернетом. Бота лучше перенести в облако.
- Переходим в раздел Облачная платформа внутри панели управления.
- Создаем сервер. Для работы нашего приложения много мощностей не нужно, поэтому будет достаточно одного ядра 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, обрабатывать команды и сообщения пользователя, реагировать на нажатия кнопок и колбэки, но и сделали себе отличного помощника в подготовке к собеседованию и закреплению материала.