Bot


Доброго времени суток, Хабрахабр! В этой статье я хочу поделиться небольшим опытом во фрилансе по разработке чат-ботов для бизнеса. Статья будет полезна начинающим разработчикам, а также может подкинуть пару интересных идей для более опытной аудитории.


Дальнейший материал рассчитан на людей, которые представляют себе как создается простой express сервер, а также имеют базовый опыт работы с MongoDB.


Несколько лет назад, я со своей командой знакомых, столкнулся с интересным заказом. Нужно было реализовать инструмент для одной IT конференции. Этот сервис должен был уметь собирать моментальный feedback от аудитории и делиться информацией о ходе мероприятия. В результате обсуждений мы пришли к созданию Telegram бота. Это было самое простое и дешевое решение на тот момент.


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


Что должен уметь наш бот?


  • Отправлять расписание мероприятия в виде telegra.ph ссылки.
  • Шарить ссылку на сайт или чат мероприятия.
  • Уметь рассылать уведомления пользователям из админки.

Систему голосования мы реализуем в следующей части.


Что нам понадобится для реализации проекта?


  • mongo db — база данных, в которой мы будем хранить пользователей и тексты сообщений.
  • express.js — библиотека для создания сервера на node.js, который поможет реализовать администраторскую панель для рассылки сообщений и управления настройками бота.
  • node-telegram-bot-api — библиотека для работы с самим ботом из кода.
  • mongoose — orm для mongo db.

Получаем все необходимое от Telegram


BotFather


Для начала, необходимо создать бота на стороне Telegram и получить его токен.


Чтобы это сделать, находим в самом Telegram бота по имени @BotFather.


После короткого опроса, BotFather выдаст нам токен и ссылку на созданного бота.


Разворачиваем базу и коннектим Mongoose


Mlab


Можно воспользоваться сервисом mlab.com для создания бесплатной mongo базы.
На скриншоте я подчеркнул место где располагается токен.


Обратите внимание: dbuser и dbpassword нужно заменить на указанные при создании базы данные для входа.


Для работы с базой воспользуемся библиотекой mongoose.js


В начале главного файла app.js коннектим mongoose с базой через токен, полученный от mlab.


var mongoose = require('mongoose');

mongoose.connect('Сюда передаем наш токен', { useMongoClient: true });

Bot-backend


Прежде чем мы приступим к разработке нашего проекта, давайте посмотрим как создается простой Telgram bot.


Чтобы четко разделить бота, с которым работет клиент на стороне Telegram и его логику обработки событий на стороне нашего сервера, назовем серверную часть Bot-backend. Вся суть реализации бот-бэкенда заключается в обработке ответных действий на сообщения пользователя.


Сами обработчики "handlers" бывают нескольких типов. Мы можем реагировать на какой-то конкретный текст, нажатие кнопки или отправку пользователем файлов. Посмотрим как это работает на примере.


Для начала заимпортим библиотеку:


var TelegramBot = require('node-telegram-bot-api');

После этого создадим конкретную реализацию api, отправив в конструктор полученный от телеграмма токен.


var bot = new TelegramBot(telegramToken, { polling: true });

Теперь у нас есть объект на который мы можем навешивать наши обработчики событий.


Отправка сообщения пользользователю


Для отправки сообщения нам понадобятся три вещи:


  • Id пользователя, которое мы можем вытащить из пришедшего сообщения.
  • Текст сообщения (можно использовать Markdown или Html разметку)
  • Набор опций сообщения.

bot.sendMessage(clientId, 'Привет, хабр!', messageOptions);

Что представляют из себя опции сообщения? Это некий объект, который содержит в себе настройки для отображения сообщения, а также набор кнопок. В данном случае, мы говорим что сообщение может быть распарсено как html разметка, выключаем превью ссылок, если таковые содержаться в сообщении, и передаем набор кнопок. Более подробно с кнопками мы разберемся дальше.


var messageOptions = {
    parse_mode: "HTML",
    disable_web_page_preview: false,
    reply_markup: JSON.stringify({
        inline_keyboard: [[{
            text: 'Название кнопки',
            callback_data: 'do_something'
        }]]
    })
}

Обратите внимание, callback_data имеет ограниченный размер. Более подробно об этом читайте в докумнтации Telegram к BotApi.


Обработчики событий


Все обработчики событий я рекомендую выносить в отдельный каталог handlers.


Рассмотрим базовый handler, который срабатывает при первом обращении к боту, В самом начале диалога с ботом, Telegram автоматически отсылает сообщение "/start", которое мы ловим регуляркой. После этого достаем telegram id пользователя и отсылаем ему ответное соообщение.


bot.onText(new RegExp('\/start'), function (message, match) {
    // вытаскиваем id клиента из пришедшего сообщения
    var clientId = message.hasOwnProperty('chat') ? message.chat.id : message.from.id;
    // посылаем ответное сообщение
    bot.sendMessage(clientId, 'Some message', messageOptions);
});

Ниже реализован handler другого типа, он реагирует на нажатие кнопки из тех, что были посланы вместе с MessageOptions.


bot.on('callback_query', function (message) {
    var clientId = message.hasOwnProperty('chat') ? message.chat.id : message.from.id;
    // То что мы записали в callback_data у кнопок приходит в message.data
    if(message.data === 'do_something'){
        bot.sendMessage(clientId, 'Button clicked!', messageOptions);
    }
});

Для удобства можно сделать несколько методов для создания кнопок


var BotUtils = {
    // Получение id клиента из сообщения
    getClientIdFromMessage: function (message) {
        return message.hasOwnProperty('chat') ? message.chat.id : message.from.id;
    },

    // Создание обычной кнопки с callback_data
    buildDefaultButton : function (text, callback_data) {
        return [{
            text: text,
            callback_data: callback_data
        }]
    },

    // Создание кнопки-ссылки на внешний ресурс
    buildUrlButton : function (text, url) {
        return [{
            text: text,
            url: url
        }]
    },

    // Заготовка для кнопки "поделиться"
    buildShareButton: function (text, shareUrl) {
        return [{
            text: text,
            url: 'https://telegram.me/share/url?url=' + shareUrl
        }]
    },

    // Сборка настроек для сообщения
    buildMessageOptions: function (buttons) {
        return {
            parse_mode: "HTML",
            disable_web_page_preview: false,
            reply_markup : JSON.stringify({
                inline_keyboard: buttons
            })
        }
    }
};

Главное меню


Hello


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


Регистрацию handlerа я вынес в отдельный класс, который примимает в метод register самого бота и базовый набор опций.


var StartHandler = {
    register: function (bot, messageOptions) {
        var clientMessage = new RegExp('\/start');

        bot.onText(clientMessage, function (message, match) {
            //Получение Id пользователя я вынес в отдельный метод getClientIdFromMessage в классе BotUtils.
            var clientId = BotUtils.getClientIdFromMessage(message);

            // В этом месте мы вызываем UserService с помощью которого сохраняем пользователя в базу при первом обращении к боту.
            bot.saveUser(clientId, function (saveErr, result) {
                if (saveErr) {
                    bot.sendMessage(clientId, 'Some error! Sorry', messageOptions);
                    return;
                }
                // Теперь текст сообщения мы получаем из базы. Дальше мы сможем редактировать их из админки, не правя код самого проекта.
                MessagesService.getByTitle('start', function (getErr, message) {
                    if (getErr) {
                        bot.sendMessage(clientId, 'Some error! Sorry', messageOptions);
                    } else {
                        bot.sendMessage(clientId, message.text, messageOptions);
                    }
                });
            });
        });
    }
};

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


Теперь давайте реализуем работу всех сервисов.


Работа с MongoDB


Collections


В самой базе нам потребуется две коллекции users и messages. Можно создать их прямо на сайте mlab.


Теперь опишем основные модели данных.


UserModel будет содержать в себе строковое поле telegramId. В дальнейшем можно добавить имя, номер телефона, ссылку на аватарку и другие данные, которые понадобятся для ваших задач.


 var mongoose = require('mongoose');
 var Schema = mongoose.Schema;
 var UserSchema = new Schema({
     telegramId: String
 });
 var User = mongoose.model('user', UserSchema);

MessageModel будет содержать название и текст сообщения. Их можно будет редактировать из админки. Изменение текста не потребует вмешательства в сам проект.


 var mongoose = require('mongoose');
 var Schema = mongoose.Schema;
 var MessageSchema = new Schema({
     title : String,
     text: String
 });
 var Message = mongoose.model('message', MessageSchema);

Сервисы


Рассмотрим сохранение пользователя, о котором говорилось выше. Сделам метод проверки на то что пользователь новый (его нет в базе).


isNew: function (telegramId, callback) {
    // Делаем поиск по ид, проверяем что такой пользователь есть, если нет то возвращаем true
    UserModel.findOne({telegramId: telegramId}, function (err, existingUser) {
        if (err) {
            callback(err, null);
            return;
        }
        if (existingUser) {
            callback(null, false);
        } else {
            callback(null, true);
        }
    });
}

Во время сохранения пользователя используем метод, реализованный выше. Если пользователь новый, создаем новый объект и сохраняем его в базу.



saveUser: function (telegramId, callback) {
    // Здесь делаем проверку на то что пользователя нет в базе
    // this вызывается потому что оба метода я разместил в одном объекте UserService
    this.isNew(telegramId, function (err, result) {
        if (err) {
            callback(err, null);
            return;
        }
        if (result) {
            // Создаем новый дата объект на основе модели вызываем метод save
            var newUserDto = new UserModel({
                telegramId: telegramId
            });
            newUserDto.save(function (err) {
                if (err) {
                    callback(err, null);
                } else {
                    callback(null, true);
                }
            });
        }else{
            callback(null, false);
        }
    });
}

Дальше реализуем сохранение и получение сообщений в MessageService.


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


var MessagesService = {
    getByTitle: function (title, callback) {
        MessageModel.findOne({title: title}, function (err, message) {
            if (err) {
                callback(err, null);
            }
            else {
                callback(null, message);
            }
        });
    }
};

Express сервер и администраторская панель


Admin


HomeController


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


homeController: function (request, response) {
    UserService.getAll(function (err, users) {
        if (err) {
            return;
        }
        response.render('main', {users: users});
    });
}

Сама вьюшка (main.ejs) может выглядеть примерно так:


<h2>Массовая рассылка</h2>
<form method="POST" action="/globalmessage">
    <h3>Сообщение:</h3>
    <textarea class="form-control" rows="3" type="text" name="message">"Напишите что-нибудь..."</textarea>
    <br/><br/>
    <input align="right" class="btn btn-success" type="submit" value="Отправить">
</form>

<h2>Все кто взаимодейсвтовал с ботом:</h2>
<ul>
    <% for(var i=0; i<users.length; i++) {%>
    <li class="list-group-item list-group-item-info">
        <%= users[i].telegramId %>
    </li>
    <% } %>
</ul>
<br/>
<a href="/" class="btn btn-success">Обновить</a>

GlobalMessageController


Он обрабатывает Post запрос после нажатия кнопки "Отправить", вызывая рассылку сообщений по всем пользователям из базы, которых мы сохраняем при первом обращении к боту.


globalMessageController: function(request, response) {
    var message = request.body.message;
    var bot = this.bot;
    UserService.getAll(function (err, users) {
        if (err) {
            return;
        }
        users.forEach(function (user) {
            bot.sendMessage(user.telegramId, message, {});
        });
    });
    response.redirect('/');
}

Конфигурация и отладка


Для запуска проекта необходимо иметь Telegram token и connection string от базы данных.
Для разработки и продакшн версии создадим две отдельные конфигурации и поместим их в config.json.


Более правильным решением было бы вынести их в переменные окружения сервера.


 getConfig: function () {
   var configJson = JSON.parse(fs.readFileSync('./src/config.json', 'utf8'));
   if(process.env.NODE_ENV === 'production'){
     return configJson.production;
   }else{
     return configJson.development;
   }
 }

Для отладки можно использовать библиотеку simple-node-logger, которая будет писать все действия с Logger.notify() в logfile.log, который можно будет просмотреть через ssh или ftp на сервере.


var nodeLogger = require('simple-node-logger');

var Logger = {
    logger: nodeLogger.createSimpleLogger('logfile.log'),
    notify: function (data) {
        this.logger.info(data, new Date().toJSON());
    }
};

Исходники


Полный код всего проекта лежит тут: GitHub


Можете свободно использовать его как основу для своих проектов. Настоятельно рекомендую провести масштабный рефакториг, а также переписать на ES6 или TypeScript.


Свой telegram token и mongo connection string необходимо прописать в файле /src/config.json


В качестве хостинга, я рекомендую использовать Heroku. Единственным неудобством будет засыпание сервера через 30 минут. Для этого существует древний лайфхак с пингованием (можете погуглить специальные сервисы для этого).


Продолжение


В следующих частях я расскажу как прикрутить систему голосования и возможность оставлять заявки. А дальше мы попробуем реализовать небольшого Scrum бота для корпоративного мессенджера Slack.


Спасибо за внимание, хабровчане!

Комментарии (2)


  1. TotalPipets
    17.11.2017 14:14

    Спасибо за статью!
    Как раз начальство спрашивало про подобную возможность для наших клиентов и тут ваша статья прямо в помощь. Буду ждать продолжения.


  1. alphaZ
    17.11.2017 14:14

    Heroku плохо с pooling'ом дружит. Лучше, уж если хостить на Heroku использовать WebHook.

    Пример бота с WebHook.