Так уж вышло, что род моей деятельности тесно переплетен с созданием ботов для Telegram. Писать я их начал сразу после появления Telegram Bot API, тогда никаких инструментов для этого не было. Пришлось самому писать библиотеку для работы с API, о чем я частично уже рассказывал в своей предыдущей статье. С течением времени библиотека несколько раз была переписана и в итоге обросла разными фишками. В статье я постараюсь рассказать о том, как с ее помощью писать ботов.





Для работы с API прежде всего понадобится token, чтобы его получить достаточно написать этому боту и проследовать его инструкциям.

Начну сразу с примера простого бота:

'use strict'

var tg = require('telegram-node-bot')('YOUR_TOKEN')

tg.router.
    when(['ping'], 'PingController')

tg.controller('PingController', ($) => {
    tg.for('ping', () => {
        $.sendMessage('pong')
    })
}) 


Работает!



А теперь разберем что там написано:

var tg = require('telegram-node-bot')('YOUR_TOKEN') 

Тут все понятно, просто объявляем модуль и передаем ему наш токен.

tg.router.
    when(['ping'], 'PingController')

Далее мы объявляем команды и роутеры, которые отвечают за эти команды.

tg.controller('PingController', ($) => {
    tg.for('ping', () => {
        $.sendMessage('pong')
    })
}) 

После мы создаем контроллер «PingController» и объявляем в нем обработчик команды ping.

Когда бот получит команду от пользователя, он поймет, что за команду ping отвечает контроллер «PingController» и исполнит обработчик команды ping.

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

Роутер


Как я уже писал выше, роутер отвечает за связь команд и контроллеров, которые эти команды обрабатывают.
Очевидно, что один контроллер может обрабатывать несколько команд.
Так же у роутера есть функция otherwise, с помощью которой можно объявить контроллер, который будет обрабатывать непредусмотренные команды:

tg.router.
    when(['test', 'test2'], 'TestController'). // "TestController" будет обрабатывать как команду test, так и команду test2
    when(['ping'], 'PingController'). 
    otherwise('OtherController') //"OtherController" будет вызван для всех остальных команд.



Контроллер


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

tg.controller('TestController', ($) => {
    tg.for('test', () => {
        $.sendMessage('test')
    })
    tg.for('test2', () => {
        $.sendMessage('test2')
    })
}) 

В контроллере также можно писать любые свои функции, переменные.

Scope


Каждый контроллер принимает особую переменную $ — scope.
В ней хранится все что нам нужно знать о запросе:


  • chatId — id чата, откуда пришел запрос
  • user — информация о пользователе, который отправил запрос, подробнее тут
  • message — вся информация о сообщении, подробнее
  • args — сообщение, которое отправил пользователь, но без самой команды. (Если пользователь отправит "/start 1" в args будет — «1»)


Вы могли заметить, что, например, функцию sendMessage мы вызывали с помощью scope. Дело в том, что помимо полей описанных выше, scope также содержит все функции библиотеки с уже прописанным chatId для конкретного чата. Ту же функцию sendMessage можно вызвать напрямую:

tg.controller('TestController', ($) => {
    tg.for('test', () => {
        tg.sendMessage($.chatId, 'test')
    })
    tg.for('test2', () => {
        tg.sendMessage($.chatId, 'test2')
    })
}) 

Согласитесь, не очень удобно писать id чата, учитывая, что мы пишем в тот же чат, откуда пришло сообщение.

Цепочка вызовов


Иногда мы что-то спрашиваем у пользователя и ждем от него какой-либо информации. Как это реализовать? Конечно, мы можем хранить состояние пользователей и в зависимости от него обрабатывать это в «OtherController», но это не совсем красиво, и ломает структуру и читаемость.
Для таких случаев у scope есть функция waitForRequest:
tg.controller('TestController', ($) => {
    tg.for('/reg', ($) => {
         $.sendMessage('Send me your name!')
         $.waitForRequest(($) => {
             $.sendMessage('Hi ' + $.message.text + '!')
         }) 
    })   
}) 

Функция waitForRequest принимает один аргумент — callback, который она вызовет когда пользователь отправит следующее сообщение. В этот callback передается новый scope. Как видно из примера выше — мы просим пользователя ввести его имя, ждем его следующее сообщение и приветствуем его.

Навигация



Допустим у нашего бота есть авторизация, в главном контроллере нам нам нужно как-то проверять авторизован ли пользователь и перенаправлять его в логин, но как? Для этого есть функция routeTo, которая принимает команду и выполняет ее как если бы ее отправил нам пользователь:
tg.controller('StartController', ($) => {
    tg.for('/profile', ($) => {
        if(!logined){ // какая то логика авторизации
            $.routeTo("/login") // перенаправляем пользователя
        }       
    }) 
})


Формы


Часто бывает, что нужно узнать у пользователя какую-то информацию, для этого есть генератор форм:
var form = {
    name: {
        q: 'Send me your name',
        error: 'sorry, wrong input',
        validator: (input, callback) => {
            if(input['text']) {
                callback(true)
                return
            }

            callback(false)
        }
    },
    age: {
        q: 'Send me your age',
        error: 'sorry, wrong input',
        validator: (input, callback) => {
            if(input['text'] && IsNumeric(input['text'])) {
                callback(true)
                return
            }

            callback(false)
        }
    },
    sex: {
        q: 'Select your sex',
        keyboard: [['male'],['famale'], ['UFO']],
        error: 'sorry, wrong input',
        validator: (input, callback) => {
            if(input['text'] && ['male', 'famale', 'UFO'].indexOf(input['text']) > -1) {
                callback(true)
                return
            }

            callback(false)
        }
    },         
}

$.runForm(form, (result) => {
    console.log(result)
})  

Как видно из кода у каждого поля есть сообщение отравляемое пользователю, валидатор, в который приходит сообщение от пользователя и сообщение об ошибке в случае неверности вводимых данных. Также можно отправить клавиатуру (keyboard).
Функция runForm вернет нам объект с теми же полями какие мы написали в самой форме, в нашем случае это name и age.

Меню



Похожий инструмент есть и для меню:
$.runMenu({
    message: 'Select:',
    'Exit': {
        message: 'Do you realy want to exit?',
        'yes': () => {

        },
        'no': () => {

        }
    } 
})  

Меню автоматически создает клавиатуру с названиями полей и отправляет ее вместе с сообщением, которое мы указываем в поле message.
Пункт меню может быть как объектом так и функцией. Если это объект, то пользователь получит подменю, а если функция, то она будет вызвана и позволит нам обработать запрос пользователя.

Также есть возможность задавать расположение кнопок в клавиатуре меню, за это отвечает поле layout, если его не передавать совсем, то на каждой строке будет одна кнопка. Можно передать максимальное число кнопок на строке или массив количества кнопок для каждой строки:
$.runMenu({
    message: 'Select:',
    layout: 2,
    'test1': () => {}, //будет на первой строке
    'test2': () => {}, //будет на первой строке
    'test3': () => {}, //будет на второй строке
    'test4': () => {}, //будет на третей строке
    'test5': () => {}, //будет на четвертой строке
})  


$.runMenu({
    message: 'Select:',
    layout: [1, 2, 1, 1],
    'test1': () => {}, //будет на первой строке
    'test2': () => {}, //будет на второй строке
    'test3': () => {}, //будет на второй строке
    'test4': () => {}, //будет на третей строке
    'test5': () => {}, ///будет на четвертой строке
}) 


Функции API


У всеж функций для работы с API есть как обязательные параметры, так и необязательные. Например функция sendMessage — по документации у нее для обязательных параметра (chatId, photo), но мы можем передать и любые дополнительные:
var options = {
     reply_markup: JSON.stringify({
         one_time_keyboard: true,
          keyboard: [['test']]
    })
}	            
$.sendMessage('test', options)


При этом последним параметром всегда является callback.

Вот список поддерживаемых на данный момент функций API с обязательными параметрами (напомню, что если вызывать их из scope, то параметр chatId не нужен):

  • sendPhoto(chatId, photo)
  • sendDocument(chatId, document)
  • sendMessage(chatId, text)
  • sendLocation(chatId, latitude, longitude)
  • sendAudio(chatId, audio)
  • forwardMessage(chatId, fromChatId, messageId)
  • getFile(fileId)
  • sendChatAction(chatId, action)
  • getUserProfilePhotos(userId)
  • sendSticker(chatId, sticker)
  • sendVoice(chatId, voice)
  • sendVideo(chatId, video)


Примеры вызова некоторых функций:
var doc =  {
    value: fs.createReadStream('file.png'), //stream
    filename: 'photo.png',
    contentType: 'image/png'
}

$.sendDocument(doc)

$.sendPhoto(fs.createReadStream('photo.jpeg'))

$.sendAudio(fs.createReadStream('audio.mp3'))

$.sendVoice(fs.createReadStream('voice.ogg'))

$.sendVideo(fs.createReadStream('video.mp4'))

$.sendSticker(fs.createReadStream('sticker.webp'))



На этом все, спасибо всем кто осилил статью, а вот и ссылка на GitHub — github.com/Naltox/telegram-node-bot и NPM: npmjs.com/package/telegram-node-bot

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


  1. Abrikos
    02.02.2016 10:01
    +3

    А $.waitForRequest() не будет срабатывать на другого пользователя?
    А аргументы replyTo, selective и др. есть возможность передавать?


  1. feverqwe
    02.02.2016 10:21

    API обертки все же сильно ограничен по сравнению с node-telegram-bot-api, начиная от того, что не возвращает Promise (это очень удобно позволяет делать сложные конструкции из асинхронных функций), заканчивая очень ограниченными функциями sendPhoto и прочих (почему нельзя задать имя файла и произвольный mime?).
    Так же не вижу в коде какого либо показа отладочной информации, если что то пошло не так.
    Я тоже являюсь автором пары ботов с аудиторией 100+ человек, и со временем пришел к выводу что на callback'х далеко не уедешь.


    1. Altox
      02.02.2016 11:09

      Спасибо за замечания, про ограниченность обертки вы, наверное, правы и я буду дорабатывать ее в следующих версиях. Насчет Promise: мне кажется тут у всех свое мнение, кому-то они нравятся, кому-то нет. А для сложных конструкций у меня есть функции waitForRequest, waitForRequest и runMenu.


      1. feverqwe
        02.02.2016 11:54

        Вот простой кейс из моего бота — бот отсылает фото, и бывает, что API глючит, бывает что картинка битая, бывает что бота кикнули из чата. Так вот, нужно сделать, в зависимости от ошибки повтор отправки сообщения (более 1 раза возможно), нужно удалить пользователя и взять следующего и попробовать повторить (если кикнули), нужно отослать текстовое сообщение с ссылкой на картинку (если все совсем плохо).
        Таким образом, если попробовать построить подобное на collback'х то можно очень сильно запутаться. Я даже и не знаю что кроме Promise тут может помочь.
        Или же, банальное — есть лимиты на отправку сообщений в секунду, надо уметь считать сообщения на уровне обертки, что бы не превысить лимиты и если они превышены — отправить через секунду. Тут тоже Promise отлично решает задачу.


      1. hell0w0rd
        02.02.2016 15:05

        Это холиварный вопрос, но все же. Чем вас не устраивают промисы? По скорости bluebird реализация почти такая же, как коллбэки, а удобнее в разы.
        Плюс на промисах базируется async-await, который уже в stage 2.


  1. Stan_1
    02.02.2016 10:37

    А как решается проблема получения сообщений в групповых чатах? Включаете setprivacy и смотрите весь поток сообщений? И еще момент. Допустим, один из участников группового чата послал команду. waitForResponse будет ждать следующей информации именно от этого же участника, или будет рассматривать любое первое пришедшее сообщение как информацию?


    1. Altox
      02.02.2016 11:12

      Честно говоря задача написания ботов для групповых чатов не стояла. Функция waitForRequest будет ждать информацию от любого участника беседы, но, я думаю, будет не сложно отфильтровать нужного по $.user.id


  1. XNoNAME
    02.02.2016 16:45

    А что с inline-mode? Еще не реализовано?


    1. Altox
      02.02.2016 17:08

      Еще нет, скоро будет.