Написать пост

Квест-бот — конкурс пет-проектов

Разработала игру для участников конференции, повышающую "эфирное" время стендов и проверяющую игроков на профпригодность.

Обложка поста Квест-бот — конкурс пет-проектов

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

Суть игры

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

			Как называется методология разработки ПО, которая предполагает быстрые итерации разработки и гибкий подход к изменению требований? 

А: Agile
Б: Waterfall
В: Spiral
Г: Ганнт
		

По прохождении игры победителям вручают пакеты с подарками: кружка, Moleskin, шоколад и другие призы от партнеров на сумму около четырех тысяч рублей.

Состав проекта

Для краткосрочной работы телеграм-бота на JavaScript достаточно всего трех зависимостей: node-telegram-bot-api, SQLite для хранения данных игроков и util для обработки параллельных событий. По сути для каждого игрока создается копия игры со своими изменяемыми состояниями, поэтому единовременно минимальный сервер (Ubuntu 22.04 1,5 Гб RAM) может поддерживать игру почти сотни человек:

			{
    "dependencies": {
      "node-telegram-bot-api": "^0.61.0",
      "sqlite3": "^5.1.6",
      "util": "^0.12.5"
    },
    "devDependencies": {
      "@types/node-telegram-bot-api": "^0.61.6"
    }
  }
		

Зададим константы — они помогут запустить личного бота, чей токен отдает BotFather, а также укажут, с каким типом СУБД предстоит взаимодействовать:

			const TelegramBot = require('node-telegram-bot-api');
const util = require('util');
const token = ';
const bot = new TelegramBot(token, {polling: true});
const sqlite3 = require('sqlite3').verbose();
const db = new sqlite3.Database('botquest.db');
		

Порядок методов и функций здесь сделать естественным не получится. JavaScript — интерпретируемый язык, и читает код построчно. То есть располагать объекты предстоит в таком порядке, чтобы к моменту запуска метода, обращающегося к базе данных, конфигурация была известна.

Переходы между стендами, победы, проигрыши

В этом участке кода мы проверяем прогресс игрока вне зависимости от того, новичок он или нет. Здесь же содержится обработчик для событий: «переход к следующему вопросу», «победа», «проигрыш», «изменение числа попыток».

			bot.on('callback_query', (query, data) => {
  console.log(query);
  let questionData = str.split('_');

  # Проверяем, ответил ли игрок на все вопросы
  db.get("SELECT * FROM questions WHERE code = ?", questionData[0], (err, question) => {
    if (err) return err;
    if (question) {

      if (questionData[1] == question.correct) {
        db.run("UPDATE users SET stake= ? + 1, tries=2 WHERE telegram_id = ?", question.stake, query.message.chat.id);
        console.log(question);
        db.get(
          "SELECT * FROM stake where id = ?",
          question.stake + 1,
          (err, stake) => {
            if (err) {
              return err
            }
            # Отправляем победителя на стойку администрации за подарком            if (!stake) {
              bot.answerCallbackQuery(query.id, {
                text: "Вы ответили на все вопросы, двигайтесь к стойке информации за подарком. Покажите сотруднику это сообщение об окончании игры. Мы всегда рады обратной связи (ekapatsa@infostart.ru).",
                show_alert: true
              });
              return;
            }

            # Отправляем игрока за следующим вопросом
            if (stake) {
              bot.answerCallbackQuery(query.id, {
                text: util.format("Верный ответ! Иди дальше к стойке под названием %s", stake.name),
                show_alert: true
              });
          });

      } else {
        # Обновляем число попыток в случае неверного ответа
        db.get("SELECT * FROM users WHERE telegram_id = ?", query.message.chat.id, (err, user) => {
          if (err) return err;
          if (user) {
            if (user.tries === 2) {
              db.run("UPDATE users SET tries=tries-1 WHERE telegram_id = ?", query.message.chat.id)
              bot.answerCallbackQuery(query.id, {
                text: "Ответ неверный! У тебя осталась 1 попытка.",
                show_alert: true
              });
            } else if (user.tries === 1) {
              db.run("UPDATE users SET tries=tries-1 WHERE telegram_id = ?", query.message.chat.id);

              # Объявляем о проигрыше
              bot.answerCallbackQuery(query.id, {text: "К сожалению, ты потратил все попытки! Подсказка: попробуй еще раз с телефона друга"});
            }
          }
        })

      }
    }
  })
});
		

Когда игрок вводит верный код, получает очередной вопрос:

			bot.onText(/^([0-9]{4})$/, (msg, match) => {
  bot.sendMessage(
    msg.chat.id,
    util.format("Ищу задание под номером %s", match[0])
  );
  # Выбираем вопрос, к которому привязан код
  db.get("SELECT * FROM questions WHERE code = ?", match[0], (err, question) => {
    if (err) return err;
    if (!question) {
      bot.sendMessage(
        msg.chat.id,
        util.format("Не нашел такого задания %s", match[0])
      );
      return;
    }

    
    if (question) {
      db.run("UPDATE users SET progress_question = ?, stake = ? WHERE telegram_id = ?", match[0], question.stake, msg.chat.id)
      # Собираем вопрос из таблицы questions
      bot.sendMessage(msg.chat.id, 'Нашел такое задание.');
      let option = {
        parse_mode: 'Markdown',
        reply_markup: {
          inline_keyboard: [
            [
              {text: "А", callback_data: question.code + "_1"},
              {text: "Б", callback_data: question.code + "_2"},
              {text: "В", callback_data: question.code + "_3"},
              {text: "Г", callback_data: question.code + "_4"}
            ]
          ]
        }
      }

      bot.sendMessage(
        msg.chat.id,
        question.context,
        option
      )
    }

    return;
  })

})
		

Проверка игрока

Начинается игра с проверки пользователя. Если запись с таким ID Telegram уже есть в БД, то мы извлекаем число пройденных этапов. И затем высылаем новый вопрос:

			bot.onText(/\/start/, (msg, match) => {

const chatId = msg.chat.id;  # Проверяем, играл ли человек раньше
  db.get("SELECT * from users where telegram_id = ?", msg.chat.id, (err, user) => {
    if (err) return err;

    if (user) {
      var option = {
        "parse_mode": "Markdown",
        "reply_markup": {}
      };

      # Восстанавливаем прогресс игрока
      bot.sendMessage(chatId, 'А вы уже регистрировались у нас! Я поищу, на каком вопросе вы остановились.', option);

      if (!user.progress_question) {
        db.get("SELECT * FROM stake ORDER BY id ASC LIMIT 1", (err, stake) => {
          if (err) {
            return err
          }

if (stake) {            # Если игрок мошенничает и высылает случайный код, система отправляет его к предыдущей стойке
            bot.sendMessage(
              chatId,
              util.format('Не нашел такого вопроса, пожалуйста, пройдите к стойке под названием ?', stake.name),
              option
            );
          }
        })
        return;
      }

      # Выбираем из базы вопрос, который еще не задавался игроку
      db.get("SELECT * FROM questions WHERE code = ?", user.progress_question, (err, question) => {
        if (err) return err;
        if (question) {
          bot.sendMessage(chatId, 'Нашел такой вопрос.');
          let option = {
            parse_mode: 'Markdown',
            reply_markup: {
              inline_keyboard: [
                [
                  {text: "А", callback_data: question.code + "_1"},
                  {text: "Б", callback_data: question.code + "_2"},
                  {text: "В", callback_data: question.code + "_3"},
                  {text: "Г", callback_data: question.code + "_4"}
                ]
              ]
            }
          }

          bot.sendMessage(
            msg.chat.id,
            question.context,
            option
          )
        }
      })

    } else {
      var option = {
        "parse_mode": "Markdown",
        "reply_markup": {
          "resize_keyboard": true,
          "one_time_keyboard": true,
          "keyboard": [[{
            text: "Отправить мой номер телефона",
            request_contact: true,
          }]]
        }
      };

      # Приветствуем игроков и просим номер телефона
      bot.sendMessage(chatId, 'Добрый день! Это квест-бот! Спасибо, что посетили нас. Вас ждут подарки от партнеров, баллы и скидки. Чтобы начать игру, нам нужен ваш номер телефона для регистрации.', option);
    }
    return;
  })
});
		

Ниже привожу участок кода, отвечающий за запись данных игрока во время регистрации. Мы собираем имена, номера телефонов и Telegram ID. Стойки и вопросы миксуются, то есть у каждого игрока их набор индивидуальный. Это предотвращает игру толпой и минимизирует слив ответов:

			bot.on("contact", (msg) => {

  # Прячем клавиатуру после отправки телефона
  let optionRemoveKeyBoard = {
    "parse_mode": "Markdown",
    "reply_markup": {
      "remove_keyboard": true,
    }
  }

  # Проверяем наличие игрока в базе и, если он новичок, записываем его данные
  db.get("SELECT * from users where telegram_id = ?", msg.chat.id, (err, user) => {
    if (err) {
      return err;
    }

    if (user) {
      console.log(user)

    } else {
      db.run("INSERT INTO users (phone, username, telegram_id) values (?, ?, ?)", msg.contact.phone_number, msg.chat.username, msg.chat.id)

      # Направляем игрока к первому случайному стенду
      bot.sendMessage(
        msg.chat.id,
        util.format(
          'Спасибо %s за регистрацию по номеру %s! Думаю, вас пора отправить к первому заданию.',
          msg.contact.first_name,
          msg.contact.phone_number
        ),
        optionRemoveKeyBoard)

      # Обновляем прогресс игрока, если он ответил на вопрос правильно
      db.get("SELECT * FROM stake ORDER BY id ASC LIMIT 1", (err, stake) => {
        if (err) {
          return err
        }

       
        if (stake) {
          //db.run("UPDATE users SET progress_question = ? WHERE telegram_id = ?", question.id, msg.chat.id);
          let option = {
            parse_mode: 'Markdown',
          }

          bot.sendMessage(
            msg.chat.id,
            util.format("Будьте добры, пройдите на стойку под названием ?", stake.name),
            option
          )
        }
        return;
      });
    }
    return;
  });
});
		

Ппредусматриваем вывод ошибок в консоль:

			bot.on('polling_error', (error) => {
  console.log(error);  // => 'EFATAL'
});
		
Когда настало время игры, в мои обязанности входила лишь поддержка игроков. Проблем было немного, лишь три раза бот прекращал отвечать из-за ошибки, которую не удалось воспроизвести. В большинстве случаев перезапуск командой /start позволял продолжать квест.

SQL через командную строку

Я мониторила игроков, вычищала тестировщиков по окончании квеста через консоль (потому что было интересно освоить CLI-версию sqlite3).

Для чтения, записи, обновления и удаления было достаточно проложить путь к файлу.db:

			cd path/to/database.db
		

И запустить предварительно установленную библиотеку:

			sqlite3 botquest.db
		

Если нужно было проверить число игроков и донести призы впоследствии, подходил SQL-запрос, выдающий число победителей (stake – число пройденных вопросов от 0 до 10):

			SELECT * FROM users WHERE stake = 10;
		

Хранилище отдавало такой результат:

			|id      |phone      |username                     |tg_id     |tries_left|code|stake  |
|--------|-----------|-----------------------------|----------|----------|----|-------|
|92      |79*********|pozdnyakova_n                |3******** |1         |6065|10     |
|95      |79*********|EmotionPunk                  |5******** |1         |2690|10     |
|83      |79*********|Svetoch725                   |4******** |2         |4041|10     |
|49      |79*********|EahNot                       |2******** |2         |1879|10     |
|93      |79*********|MiroshaLazy                  |8******** |2         |3329|10     |
		

Если речь шла о помощи игроку, застрявшему с ботом-молчуном, я пару раз дарила прохождение раунда:

			UPDATE users SET stake = 6 WHERE username = 'telegram_id';
		

Если в основной код проекта внедрялись изменения (с помощью Vim, прямо на месте), то с помощью process manager бот перезапускался:

			pm2 restart all
		

Заключение

В мае 2023 года этот квест прошло около 150 человек, что весьма здорово, учитывая нулевые затраты на продвижение. И это увеличило время пребывания на стендах примерно на 11 часов.

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

Следите за новыми постами
Следите за новыми постами по любимым темам
457 открытий2К показов