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

от автора

image

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

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

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

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

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

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

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

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

После того, как вы всё сделали, вам выдают токен. На этом 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 и получаем:

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

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);   } }; 

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

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

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

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 (пример), и всё готово. Логи есть, демон есть, на текущий момент аптайм около двух месяцев.

Заключение

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

ссылка на оригинал статьи https://habr.com/ru/company/domclick/blog/529440/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *