Telegram-бот как фитнес-менеджер во время пандемии

image

С приходом коронавируса мир сошел с ума и появилась куча ограничений, которые полностью поменяли нашу жизнь. Меня зовут Эмиль Фролов, я руковожу разработкой команды внутренних сервисов в ДомКлике и сегодня я поделюсь с читателями историей про создание бота, который помог нам справиться с некоторыми тяготами ковид-ограничений.

Наша компания — одна из тех, которые стараются сделать жизнь своих сотрудников максимально комфортной. В офисе есть практически всё: столовые, кофейни, куча мест для отдыха и спортзал со свободным посещением. Вот о нем мы сегодня и поговорим. В период пандемии одним из ограничений было определенное количество людей, единовременно присутствующих в спортзале. Было создано приложение, в котором можно записаться на определенное время, если есть места.

Сначала всё было хорошо и мест всем хватало, но по мере выхода людей в офис мест больше не становилось, и запись в зал превратилась в попытки поймать момент, когда освободится местечко. Как говорится, лень — двигатель прогресса: почти сразу как, начались трудности с запись, пришла в голову идея создать бота, который будет это делать за меня.

Некоторые пункты могут показаться кому-то очевидными, но я их всё равно тут оставлю для тех, кто сталкивается с Telegram-ботом впервые.


Как и в любом рецепте, начнем со списка ингредиентов:

  1. самый простенький виртуальный сервер (цена вопроса рублей 150/мес);
  2. клиент Telegram;
  3. Node js;
  4. любая IDE.


Шаг 1 (получаем токен)


Заходим в Telegram, пишем в поиске @botfather, а дальше следуем его инструкциям:

pi0qevtigwbpgxuiokzkzryq4bk.png

После того, как вы всё сделали, вам выдают токен. На этом Telegram можно отложить в сторонку, а токен нам понадобится чуть позже.

Шаг 2 (готовим API)


Этот шаг я подробно описывать не буду, так как у вас может быть любой другой API. Не долго думая, я зашел на сервис, предоставляющий бронь, провел реверс-инжиниринг и получил все нужные методы API для авторизации, получения списка свободных слотов и их бронирования.

Всего три метода:

  1. получение токена по логину и паролю (этот метод резервный);
  2. получение списка слотов по дате;
  3. резервирование места.


Шаг 3 (пишем бота)


Есть куча библиотек для ботов. Я выбрал вот такую, у ребят прекрасная документация и практически нет критических issue.

Создаём бота, тут всё достаточно просто:

// Подключаем бота
const TelegramBot = require('node-telegram-bot-api');

// Создаем инстанс, передаем туда токен полученый в самом начале
const bot = new TelegramBot(constants.token, {polling: true});

// Регистрируем быстрые команды
bot.setMyCommands([
  {
    command: '/auth',
    description: 'Авторизация. /auth ${token}'
  },
  {
    command: '/start',
    description: 'Старт вотчеров'
  },
  {
    command: '/list',
    description: 'Получить список вотчеров'
  },
]);


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

// слушаем ввод команды "/auth ${token}"
bot.onText(/\/auth/, async (msg) => {
  try {
    // Парсим эти данные
    const token = msg.text.split(' ')[1];
    const chatId = msg.chat.id;

    // Дальше если все введено корректно добавляем их в файл
    if (token && token.length !== 0) {
      const tokens = JSON.parse(await fs.readFile('./store/tokens.json', 'utf-8'));
      
      const data = {
        ...tokens,
        [chatId]: token
      };

      await fs.writeFile('./store/tokens.json', JSON.stringify(data));

      await bot.sendMessage(chatId, 'token registered');
    }
  } catch (e) {
    console.log(e);
  }
});


Дальше регистрируем основную нашу команду:

bot.onText(/\/start/, async (msg) => {
  try {
    const chatId = msg.chat.id;
   // Тут мы получаем список слотов на текущую неделю (рассмотрим этот метод ниже)
    const days = helpers.getCurrentWeek();

    // Отправляем шаблонное "красивое" сообщение
    await bot.sendMessage(chatId, 'На какое число посмотреть расписание?', {
      "reply_markup": {
        "inline_keyboard": days
      },
    })

    // Удаляем /start чтоб не было мусора
    await bot.deleteMessage(chatId, msg.message_id);
  } catch (e) {
    console.log(e);
  }
});


Дальше получаем даты, на которые можно подписаться:

const getCurrentWeek = () => {
  // Размер, в целом, не важен, поэтому подключаем момент и не заморачиваемся
  const currentDate = moment();

  // Берем начало текущей недели
  const weekStart = currentDate.clone().startOf('isoWeek');

  const days = [];

  // Ну и формируем даты +- от начала текущей недели, это по своему предпочтению
  for (let i = -7; i <= 12; i++) {
    const day = moment(weekStart).add(i, 'days');
    days.push([{
      // Это текст в элементе
      text: day.format("DD"),
     
      // Вот этот кусок нужен, чтобы можно было корректно отреагировать на нажатие
      // Так как это поле доступно как строка, заворачиваем JSON в строку (лучше ничего не придумал)
      callback_data: JSON.stringify({
        data: day.format('YYYY-MM-DD'),
        id: constants.WEEK_DAY
      })
    }]);
  }
  return days;
}


В итоге пишем /start и получаем:

xdpczj-c6b-akih-x8zpivugphm.png


Дальше нужно обработать выбор даты, для этого регистрируем обработчик коллбеков:

bot.on('callback_query', async (query) => {
  try {
   // Получаем всё, что нужно для обработки нажатия
    const {message: {chat, message_id} = {}, data} = query
    // Разворачиваем JSON обратно и получаем всю мету
    const callbackResponse = JSON.parse(data);

   // Наводим красоту
    await bot.deleteMessage(chat.id, message_id);

    // По вхождению коллбека разбиваем тело на команды
    if (callbackResponse.id === constants.WEEK_DAY) {
      const day = callbackResponse.data;

      // Формируем и отправляем список слотов на текущий день. Подробнее ниже.
      await sendEventsList(chat.id, message_id, day);
    }
  } catch (e) {
    console.log(e);
  }
});


Получение и отправка слотов:

 const sendEventsList = async (chatId, messageId, day) => {
  try {
    // Просто метод, который дергает метод по токену и дате
    const eventsList = await api.getEventsTimesList(day);

    if (eventsList && eventsList.length === 0) {
      await bot.sendMessage(chatId, 'На эту дату нет тренировок');
    } else {
     // Формируем шаблон тренировок на выбранную дату
      const keyboardList = eventsList.map((listItem) => ([{
        text: `${listItem.time} Мест: ${listItem.free}`,
        callback_data: JSON.stringify({
          day,
          data: listItem.time,
          id: constants.EVENTS_LIST
        })
      }]));

      // Отправляем сообщение пользователю
      await bot.sendMessage(chatId, 'За каким временем следить?', {
        "reply_markup": {
          "inline_keyboard": keyboardList
        },
      })
    }
  } catch (e) {
    console.log(e);
  }
};


Получаем вот такую красоту:

e2z0ycsawsbfypebsarnokacauw.png


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

Обработаем выбор даты:

bot.on('callback_query', async (query) => {
  try {
    const {message: {chat, message_id} = {}, data} = query
    const callbackResponse = JSON.parse(data);

    await bot.deleteMessage(chat.id, message_id);

    const watchers = await fs.readFile('./store/meta.json', 'utf-8');

    if (callbackResponse.id === constants.EVENTS_LIST) {
     // Тут, на мой взгляд, происходит самое интересное
     // Мы присваиваем новое событие конкретному пользователю
      const eventsWatchers = merge(
        JSON.parse(watchers),
        {
          [chat.id]: {
            [callbackResponse.day]: {
              [callbackResponse.data]: true
            }
          }
        }
      );

      // Обновляем файл меты
      await fs.writeFile('./store/meta.json', JSON.stringify(eventsWatchers));

      // Сразу проверяем возможность записи
      // По сути, всё, что мы будем делать дальше, это постоянно дергать этот метод
      checkEvents(eventsWatchers);
    }
  } catch (e) {
    console.log(e);
  }
});


Рассмотрим метод проверки наличия мест:

const checkEvents = async (eventsWatchers) => {
  // Вотчеры получаются так
  // const watchers = await fs.readFile('./store/meta.json');
  const chats = Object.keys(eventsWatchers);

  // Тут мы получаем список дат и слотов для каждой даты, если они есть
  const activeDays = helpers.getActiveDays(eventsWatchers);

  // Берем каждую дату
  Object.keys(activeDays).forEach(async (day) => {
    
    // Получаем список запрашиваемых слотов для этой даты
    const times = activeDays[day];

    // Проходимся по пользователям
    chats.forEach(async (chat) => {

      // Запрашиваем список слотов для текущей даты
      const eventsList = await api.getEventsTimesList(day);

      
      if (eventsList) {
        eventsList.forEach(async (event) => {
          // Проверяем, есть ли слоты на текущее время
          if (times.indexOf(event.time) !== -1 && event.free !== 0) {
              try {
                // Проверяем, есть ли в эту дату подписки на слот
                if (eventsWatchers[chat][day]) {
                  const chatTimes = Object.keys(eventsWatchers[chat][day]);

                  // Проверяем, подписан ли пользователь на этот слот
                  if (chatTimes.indexOf(event.time) !== -1) {
                    const tokens = JSON.parse(await fs.readFile('./store/tokens.json', 'utf-8'));
                    // Берем токен пользователя
                    const token = tokens[chat];
                    // Букаем слот
                    const resp = await api.bookEvent(event.bookId, token)
                    // Удаляем подписку у пользователя
                    helpers.deleteWatcher(event.time, day, chat);
                    // Говорим пользователю, что он записан на тренировку
                    bot.sendMessage(chat, `Вы записаны на ${day} ${event.time}`);
                  }
                }
              }catch (e) {
                console.log('Book error', e);
                bot.sendMessage(chat, `Ошибка записи. Попробуйте обновить токен`);
              }
          }
        });
      }
    })
  })
}


Я не претендую на идеальное решение, но оно работает стабильно и экономит кучу времени. Тут есть огромное поле для фантазии, например получение текущих подписок с возможностью удаления и т. д. Принцип один и тот же.

Шаг 4 (сервер)


Для серверной части я арендовал самый дешевый виртуальный сервер. Поставил туда NodeJS (как это сделать, в сети полно информации). В качестве демона использовал PM2, он максимально просто ставится и настраивается. Дальше просто выполнил pm2 start index.js (пример), и всё готово. Логи есть, демон есть, на текущий момент аптайм около двух месяцев.

Заключение


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

© Habrahabr.ru