29 мая 2024

🤖👨‍💻 Пишем Telegram-бота для подготовки к собеседованию на Frontend-разработчика

🔥 Алексей. Занимаюсь frontend-разработкой и пишу SQL скрипты в одной it компании. Веду личный блог о моем опыте в IT и около IT на YouTube - https://www.youtube.com/@tehno.maniak
Представляю бесплатного Telegram-бота, разработанного для эффективной подготовки к техническому собеседованию на позицию Frontend разработчика. Бот предлагает викторины по HTML, CSS, JavaScript и React, а также рейтинговый режим для соревнования с другими пользователями. Полный код проекта можно посмотреть в моем Github-репозитории.
4
🤖👨‍💻 Пишем Telegram-бота для подготовки к собеседованию на Frontend-разработчика

Описание работы Telegram-бота

В боте присутствует 4 категории вопросов: HTML, CSS, JavaScript и React.

Выбор категории вопросов в Telegram-боте для подготовки к собеседованию
Выбор категории вопросов в Telegram-боте для подготовки к собеседованию

Чтобы было интереснее решать вопросы, я добавил еще 🏆Рейтинговый режим, в котором отсутствует выбор категории и вам предстоит решать все имеющиеся в базе вопросы. За каждый правильный ответ вам присуждается 1 балл. В случае неправильного ответа игра прекратится, а ее результат будет записан в базу данных и сохранен в вашем профиле.

Для добавления элемента соревнования между другими пользователями бота я добавил кнопку – Таблица лидеров, после нажатия на которую будет выведено ТОП-10 игроков Рейтингового режима.

У каждого игрока есть свой 👨🏼‍💻Профиль, в котором можно увидеть статистику по решенным вопросам в 4 категориях и баллы за игру в 🏆Рейтинговом режиме.

Личный профиль каждого пользователя
Личный профиль каждого пользователя
👨‍💻🎨 Библиотека фронтендера
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека фронтендера»

Используемые технологии

  1. Node.js: Серверная платформа для выполнения JavaScript-кода.
  2. grammY: Фреймворк для создания Telegram-ботов.
  3. sqlite: Встраиваемая база данных для хранения результатов пользователей.
  4. date-fns: Библиотека для форматирования дат и времени.
  5. dotenv: Модуль для загрузки переменных окружения из .env файла.
package.json
         "dependencies": {
    "date-fns": "^3.6.0",
    "dotenv": "^16.4.5",
    "grammy": "^1.23.0",
    "nodemon": "^3.1.0",
    "sqlite": "^5.1.1",
    "sqlite3": "^5.1.7"
  }
    

Структура проекта

Рассмотрим структуру проекта, которая включает все необходимые файлы и директории.

Структура проекта
        tech-interview-trainer/
├── .env
├── index.js
├── package.json
├── package-lock.json
├── README.md
└── questions/
    ├── html_questions.json
    ├── css_questions.json
    ├── js_questions.json
    └── react_questions.json
└── leaderboard.db

    
  1. index.js: Основной файл проекта, содержащий весь код логики бота, включая инициализацию, обработку команд и взаимодействие с базой данных SQLite.
  2. questions/: Директория, содержащая JSON-файлы с вопросами по различным категориям (HTML, CSS, JavaScript, React).

Пример файла html_questions.json:

html_questions.json
        {
  "questions": [
    {
      "question": "Какой тег используется для создания ссылки?",
      "options": ["<link>", "<a>", "<div>", "<img>"],
      "correctOption": 1
    },
    {
      "question": "Какой тег используется для создания списка?",
      "options": ["<list>", "<ul>", "<ol>", "<menu>"],
      "correctOption": 2
    },
  ]
}

    
  1. leaderboard.db: Файл базы данных SQLite, в котором хранятся данные о пользователях, их результаты и время последней игры.
  2. .env: Этот файл содержит конфиденциальные данные, такие как токен API Telegram и ID администратора.

Пример файла .env:

Пример файла .env
        BOT_API_KEY=your-telegram-bot-api-key
ADMIN_ID=your-telegram-id

    

Создание Telegram-бота

Создадим экземпляр бота и инициализируем его с API-ключом Telegram:

index.js
        const bot = new Bot(process.env.BOT_API_KEY);
    

Определим начальное состояние сессии для каждого пользователя, которое взаимодействует с ботом:

index.js
        bot.use(session({
  initial: () => ({
    correctAnswers: {
      html: 0,
      css: 0,
      js: 0,
      react: 0
    },
    hasStartedRatingMode: false
  })
}));
    

Для скорости работы Telegram-бота добавим функцию для загрузки вопросов из JSON-файлов при запуске бота:

index.js
        async function loadQuestions() {
  const categories = {
    html: 'html_questions.json',
    css: 'css_questions.json',
    js: 'js_questions.json',
    react: 'react_questions.json'
  };
  for (const [category, file] of Object.entries(categories)) {
    try {
      const data = await fs.readFile(`questions/${file}`, 'utf8');
      questionsData[category] = JSON.parse(data).questions;
    } catch (error) {
      console.error(`Ошибка при загрузке вопросов из файла ${file}:`, error);
    }
  }
}
    

Для хранения данных о пользователях и их результатах в таблице лидеров инициализируем базу данных SQLite. Эта функция открывает (или создает) базу данных и создает таблицу leaderboard, если она еще не существует:

index.js
        async function initDatabase() {
  db = await open({
    filename: 'leaderboard.db',
    driver: sqlite3.Database
  });

  await db.exec(`
    CREATE TABLE IF NOT EXISTS leaderboard (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      username TEXT NOT NULL,
      score INTEGER NOT NULL,
      last_played TEXT NOT NULL
    )
  `);
}
    

Добавим функции для работы с базой данных SQLite для управления профилями пользователей и их результатами в таблице лидеров.

  • Функция createProfile. Эта функция создает профиль для пользователя, если его еще нет в таблице leaderboard.
index.js
        async function createProfile(username) {
  const existingEntry = await db.get('SELECT * FROM leaderboard WHERE username = ?', username);
  if (!existingEntry) {
    await db.run('INSERT INTO leaderboard (username, score, last_played) VALUES (?, ?, ?)', username, 0, 'Еще не играл');
  }
}
    
  • Функция updateLeaderboard. Эта функция обновляет таблицу лидеров для пользователя, добавляя или обновляя его запись в зависимости от результатов.
index.js
        async function updateLeaderboard(username, score) {
  const now = new Date().toISOString();
  const existingEntry = await db.get('SELECT * FROM leaderboard WHERE username = ?', username);
  if (existingEntry) {
    if (existingEntry.score < score) {
      await db.run('UPDATE leaderboard SET score = ?, last_played = ? WHERE username = ?', score, now, username);
    } else {
      await db.run('UPDATE leaderboard SET last_played = ? WHERE username = ?', now, username);
    }
  } else {
    await db.run('INSERT INTO leaderboard (username, score, last_played) VALUES (?, ?, ?)', username, score, now);
  }
}
    
  • Функция getLeaderboard. Эта функция возвращает топ 10 пользователей из таблицы лидеров, отсортированных по убыванию очков.
index.js
        async function getLeaderboard() {
  return await db.all('SELECT username, score FROM leaderboard ORDER BY score DESC LIMIT 10');
}
    
  • Функция getTotalUsers. Эта функция возвращает общее количество пользователей в таблице лидеров.
index.js
        async function getTotalUsers() {
  const result = await db.get('SELECT COUNT(*) AS count FROM leaderboard');
  return result.count;
}
    

Добавим функции обработки для команд Telegram-бота /start, /profile, /admin.

  • Команда /start: Эта команда инициализирует бота и приветствует пользователя, предлагая начать использование бота.
index.js
        bot.command('start', async (ctx) => {
  //Получение имени пользователя:
  const username = ctx.from.username || ctx.from.first_name;
  //Вызывается функция createProfile, чтобы создать профиль пользователя, если он еще не существует.
  await createProfile(username);
  //Создается клавиатура с кнопками для выбора темы.
  const startKeyboard = getStartKeyboard();
  //Отправляется приветственное сообщение и клавиатура с кнопками.
  await ctx.reply(
    'Привет! Я помогу тебе подготовиться к собеседованию. Используй команды ниже для взаимодействия с ботом:\n' +
    '/start - Начать использование бота\n' +
    '/profile - Просмотр вашего профиля',
    { reply_markup: startKeyboard }
  );
  await ctx.reply('С чего начнем? Выбирай тему👇', {
    reply_markup: startKeyboard,
  });
});
    
  • Команда /profile: Эта команда отображает профиль пользователя, включая его результаты в различных категориях.
index.js
        bot.command('profile', async (ctx) => {
  //Получение имени пользователя:
  const username = ctx.from.username || ctx.from.first_name;
  //Получение данных пользователя из базы данных:
  const result = await db.get('SELECT * FROM leaderboard WHERE username = ?', username);
  //Получение общего количества вопросов по категориям:
  const htmlQuestionsTotal = questionsData.html.length;
  const cssQuestionsTotal = questionsData.css.length;
  const jsQuestionsTotal = questionsData.js.length;
  const reactQuestionsTotal = questionsData.react.length;
  //Получение количества правильных ответов пользователя из сессии:
  const htmlCorrect = ctx.session.correctAnswers.html;
  const cssCorrect = ctx.session.correctAnswers.css;
  const jsCorrect = ctx.session.correctAnswers.js;
  const reactCorrect = ctx.session.correctAnswers.react;

  if (result) {
    const formattedDate = result.last_played === 'Еще не играл' ? result.last_played : format(new Date(result.last_played), 'dd MMMM yyyy, HH:mm', { locale: ru });
    const profileMessage = `👤 Профиль пользователя ${username}:\n` +
      `🏆 Счет в рейтинговой игре: ${result.score} очков\n` +
      `📅 Дата последней игры: ${formattedDate}\n` +
      `📚 Вопросы по HTML: решено верно ${htmlCorrect} из ${htmlQuestionsTotal}\n` +
      `📚 Вопросы по CSS: решено верно ${cssCorrect} из ${cssQuestionsTotal}\n` +
      `📚 Вопросы по JavaScript: решено верно ${jsCorrect} из ${jsQuestionsTotal}\n` +
      `📚 Вопросы по React: решено верно ${reactCorrect} из ${reactQuestionsTotal}`;

    await ctx.reply(profileMessage);
  } else {
    await ctx.reply('Профиль не найден. Начните игру в рейтинговом режиме, чтобы создать профиль.');
  }
});
    
  • Команда /admin: Эта команда предназначена для администрирования и доступна только пользователю с ID администратора. Она отображает общее количество пользователей.
index.js
        bot.command('admin', async (ctx) => {
  const userId = ctx.from.id;
  const adminId = parseInt(process.env.ADMIN_ID, 10);

  if (userId === adminId) {
    const totalUsers = await getTotalUsers();
    await ctx.reply(`Общее количество пользователей: ${totalUsers}`);
  } else {
    await ctx.reply('У вас нет прав для использования этой команды.');
  }
});
    

Осталось добавить функционал бота, связанный с обработкой сообщений пользователей и викторинами.

  • Основная функция обработки сообщений. В зависимости от текста сообщения, бот вызывает соответствующую функцию для начала викторины по выбранной категории, запуска рейтингового режима или отображения таблицы лидеров.
index.js
        bot.on('message', async (ctx) => {
  const { text } = ctx.message;
  if (text === 'Назад ↩️') {
    const startKeyboard = getStartKeyboard();

    await ctx.reply('Выберите категорию:', {
      reply_markup: startKeyboard,
    });
  } else {
    switch (text) {
      case 'HTML':
        await startQuiz(ctx, 'html');
        break;
      case 'CSS':
        await startQuiz(ctx, 'css');
        break;
      case 'JavaScript':
        await startQuiz(ctx, 'js');
        break;
      case 'React':
        await startQuiz(ctx, 'react');
        break;
      case '🏆Рейтинговый режим':
        if (!ctx.session.hasStartedRatingMode) {
          ctx.session.hasStartedRatingMode = true;
          await ctx.reply(
            'Рейтинговый режим содержит вопросы из всех категорий. За каждый правильный ответ дается балл, а при неверном ответе игра прекращается. Таблица лидеров выводит топ 10 игроков в рейтинге.'
          );
        }
        initializeRatingMode(ctx);
        await startRatingQuiz(ctx);
        break;
      case '📣Таблица лидеров':
        await showLeaderboard(ctx);
        break;
      default:
        handleQuizAnswer(ctx, text);
    }
  }
});
    
  • Функция handleQuizAnswer. Эта функция обрабатывает ответы пользователя на вопросы викторины.
index.js
        async function handleQuizAnswer(ctx, answer) {
  try {
    if (!ctx.session.currentQuestion) {
      await ctx.reply('Кажется, я забыл вопрос. Давай начнем заново.');
      return;
    }

    const correctAnswer = ctx.session.currentQuestion.options[ctx.session.currentQuestion.correctOption];

    if (answer === correctAnswer) {
      await ctx.reply('Верно!');
      ctx.session.correctAnswers[ctx.session.currentCategory]++;
      if (ctx.session.ratingMode) {
        ctx.session.score += 1;
        await startRatingQuiz(ctx);
      } else {
        await startQuiz(ctx, ctx.session.currentCategory);
      }
    } else {
      if (ctx.session.ratingMode) {
        const username = ctx.from.username || ctx.from.first_name;
        await updateLeaderboard(username, ctx.session.score);
        ctx.session.ratingMode = false;
        const startKeyboard = getStartKeyboard();
        await ctx.reply(`Ошибка! Вы набрали ${ctx.session.score} очков.`, {
          reply_markup: startKeyboard,
        });
        ctx.session.score = 0;
      } else {
        await ctx.reply('Неправильно. Попробуйте еще раз.');
      }
    }
  } catch (error) {
    console.error('Ошибка обработки ответа на вопрос:', error);
    await ctx.reply('Произошла ошибка при обработке ответа на вопрос. Попробуйте еще раз позже.');
  }
}
    
  • Функция startQuiz. Эта функция начинает викторину по указанной категории.
index.js
        async function startQuiz(ctx, category) {
  initializeQuizState(ctx, category);

  const questions = questionsData[category];
  if (!questions) {
    await ctx.reply(`Не удалось загрузить вопросы для категории ${category.toUpperCase()}. Проверьте файл: questions/${category}_questions.json`);
    return;
  }

  const questionData = getRandomQuestion(questions, ctx.session.askedQuestions[category]);
  if (!questionData) {
    const startKeyboard = getStartKeyboard();
    await ctx.reply(`Вы ответили на все вопросы по ${category.toUpperCase()}!`, {
      reply_markup: startKeyboard,
    });
    return;
  }
    
💼 Вакансии для фронтенд-разработчиков
Вакансии по фронтенду, джаваскрипт, React, Angular, Vue @jsdevjob

Заключение

В этой статье я рассмотрел основные составляющие моего Telegram-бота, необходимые для его работы. Полный код проекта можно посмотреть в моем Github репозитории. Опробовать бота можно по ссылке. У меня есть еще пара идей для улучшения его функциональности в будущем, планирую добавить блок вопросов с задачами по JavaScript и добавить больше статистик в профилях пользователей.

Telegram бот для подготовки к собеседованию на Frontend разработчика
Telegram бот для подготовки к собеседованию на Frontend разработчика

МЕРОПРИЯТИЯ

Комментарии

 
 
03 июня 2024

Привет!

Сразу бросились в глаза моменты с БД:

  1. Индексы. Хорошая практика их все же использовать. Тут прямо напрашивается уникальный индекс по username и не только ради ускорения поиска, см. далее.
  2. NULL. Вместо текста "Еще не играл" в колонке last_played корректнее просто хранить NULL. Потому что однажды захочется текст поменять или сделать версию бота на разных языках. В худшем случае в БД получиться солянка значений "Еще не играл", "Не играл", "Not played" и т.д., что не позволит внятно выбрать, например, игроков кто не начинал игру.
  3. SELECT + INSERT. Речь о функции createProfile. Для начала, такой подход не защитит от дублей при запуске более 1 процесса - с эти лучше справится уникальный индекс, см. п.1. А когда будет индекс есть прекрасная конструкция INSERT OR IGNORE, см. https://www.sqlite.org/lang_insert.html
  4. updateLeaderboard. Разновидность п.3. Есть прекрасная конструкция INSERT ... ON CONFLICT, называется такой подход upsert, см. https://www.sqlite.org/lang_upsert.html

PS В целом в БД в качестве ИД пользователя логичнее было бы хранить именно id, а не username или first_name. Хотя бы потому что эти значения можно поменять, а id постоянен. Да и вдруг захочется в дальнейшем напоминать пользователю, что он давно не тренировался - потребуется id для отправки сообщения. Читаемое имя пользователя, конечно, тоже нужно, но главным образом для отображения, а не логики.

03 июня 2024

И в контексте текущей реализации updateLeaderboard не ясно зачем вообще createProfile. Нет в базе - значит ни разу не играл, а при первом ответе создаем профиль сразу с рейтингом.

В общем, есть куда стремиться)

05 июня 2024

Спасибо большое за такой развернутый комментарий, очень полезно) Буду реализовывать))

Обновление от 30.05.2024:

  • Добавил возможность повторного прохождения вопросов в выбранной категории, даже если она уже полностью пройдена. Теперь бот спросит Вас, желаете ли вы пройти эти вопросы еще раз)

ВАКАНСИИ

Добавить вакансию

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ

LIVE >

Подпишись

на push-уведомления