С приходом коронавируса мир сошел с ума и появилась куча ограничений, которые полностью поменяли нашу жизнь. Меня зовут Эмиль Фролов, я руковожу разработкой команды внутренних сервисов в ДомКлике и сегодня я поделюсь с читателями историей про создание бота, который помог нам справиться с некоторыми тяготами ковид-ограничений.
Наша компания — одна из тех, которые стараются сделать жизнь своих сотрудников максимально комфортной. В офисе есть практически всё: столовые, кофейни, куча мест для отдыха и спортзал со свободным посещением. Вот о нем мы сегодня и поговорим. В период пандемии одним из ограничений было определенное количество людей, единовременно присутствующих в спортзале. Было создано приложение, в котором можно записаться на определенное время, если есть места.
Сначала всё было хорошо и мест всем хватало, но по мере выхода людей в офис мест больше не становилось, и запись в зал превратилась в попытки поймать момент, когда освободится местечко. Как говорится, лень — двигатель прогресса: почти сразу как, начались трудности с запись, пришла в голову идея создать бота, который будет это делать за меня.
Некоторые пункты могут показаться кому-то очевидными, но я их всё равно тут оставлю для тех, кто сталкивается с Telegram-ботом впервые.
Как и в любом рецепте, начнем со списка ингредиентов:
- самый простенький виртуальный сервер (цена вопроса рублей 150/мес);
- клиент Telegram;
- Node js;
- любая IDE.
Шаг 1 (получаем токен)
Заходим в Telegram, пишем в поиске @botfather, а дальше следуем его инструкциям:

После того, как вы всё сделали, вам выдают токен. На этом Telegram можно отложить в сторонку, а токен нам понадобится чуть позже.
Шаг 2 (готовим API)
Этот шаг я подробно описывать не буду, так как у вас может быть любой другой API. Не долго думая, я зашел на сервис, предоставляющий бронь, провел реверс-инжиниринг и получил все нужные методы API для авторизации, получения списка свободных слотов и их бронирования.
Всего три метода:
- получение токена по логину и паролю (этот метод резервный);
- получение списка слотов по дате;
- резервирование места.
Шаг 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/
Добавить комментарий