Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
С приходом коронавируса мир сошел с ума и появилась куча ограничений, которые полностью поменяли нашу жизнь. Меня зовут Эмиль Фролов, я руковожу разработкой команды внутренних сервисов в ДомКлике и сегодня я поделюсь с читателями историей про создание бота, который помог нам справиться с некоторыми тяготами ковид-ограничений.
Наша компания — одна из тех, которые стараются сделать жизнь своих сотрудников максимально комфортной. В офисе есть практически всё: столовые, кофейни, куча мест для отдыха и спортзал со свободным посещением. Вот о нем мы сегодня и поговорим. В период пандемии одним из ограничений было определенное количество людей, единовременно присутствующих в спортзале. Было создано приложение, в котором можно записаться на определенное время, если есть места.
Сначала всё было хорошо и мест всем хватало, но по мере выхода людей в офис мест больше не становилось, и запись в зал превратилась в попытки поймать момент, когда освободится местечко. Как говорится, лень — двигатель прогресса: почти сразу как, начались трудности с запись, пришла в голову идея создать бота, который будет это делать за меня.
Некоторые пункты могут показаться кому-то очевидными, но я их всё равно тут оставлю для тех, кто сталкивается с 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
(пример), и всё готово. Логи есть, демон есть, на текущий момент аптайм около двух месяцев.Заключение
Чтобы это всё сделать, пришлось покопаться в интернетах. Я постарался собрать в этой статье всё самое необходимое для написания практически любого бота. Мой бот до сих пор исправно служит, правда, в связи с новой волной пандемии потребность в нем отпала. Спасибо за внимание.