Как, опять? Ещё один туториал, пережёвывающий официальную документацию от Telegram, подумали вы? Да, но нет! Это скорее рассуждения на тему того, как построить функциональный бот-сервис используя Python3.5+, asyncio и aiohttp. Тем интереснее, что заголовок на самом деле лукавит…

Так в чём же лукавство заголовка? Во-первых, кода не 50 строк, а всего 39, а во-вторых, и бот не такой сложный, просто эхо-бот. Но, как мне кажется, этого достаточно, чтобы поверить в то, что сделать свой собственный бот-сервис не столь сложно, как может показаться.

Telegram-bot в 39 строк кода
import asyncio
import aiohttp
from aiohttp import web
import json

TOKEN = '111111111:AAHKeYAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
API_URL = 'https://api.telegram.org/bot%s/sendMessage' % TOKEN

async def handler(request):
    data = await request.json()
    headers = {
        'Content-Type': 'application/json'
    }
    message = {
        'chat_id': data['message']['chat']['id'],
        'text': data['message']['text']
    }
    async with aiohttp.ClientSession(loop=loop) as session:
        async with session.post(API_URL,
                                data=json.dumps(message),
                                headers=headers) as resp:
            try:
                assert resp.status == 200
            except:
                return web.Response(status=500)
    return web.Response(status=200)

async def init_app(loop):
    app = web.Application(loop=loop, middlewares=[])
    app.router.add_post('/api/v1', handler)
    return app

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    try:
        app = loop.run_until_complete(init_app(loop))
        web.run_app(app, host='0.0.0.0', port=23456)
    except Exception as e:
        print('Error create server: %r' % e)
    finally:
        pass
    loop.close()


Далее, в нескольких словах, что для чего и как сделать лучше из того, что уже есть.

Содержание:


  1. Что используем
  2. Как используем
  3. Что можно улучшить
  4. Реальный мир

1. Что используем


  • во-первых, Python 3.5+. Почему именно 3.5+, потому что asyncio [2] и потому что сахарные async, await etc;
  • во-вторых, aiohttp. Так как сервис на вебхуках, то он одновременно и HTTP-сервер и HTTP-клиент, а что для этого использовать, как не aiohttp [3];
  • в-третьих, почему webhook, а не long polling? Если не планируется изначально бот-рассыльщик, то интерактивность является его основной функцией. Выскажу своё мнение, что для этой задачи, бот в роли HTTP-сервера подходит лучше, чем в роли клиента. Да, и отдадим часть работы (доставку сообщений) сервисам Telegram.

И ещё, у вас должно быть подконтрольное доменное имя, валидный или самоподписанный сертификат. Доступ к серверу на который указывает доменное имя для настройки реверс-прокси на адрес сервиса.

К содержанию

2. Как используем


Сервер


Состояние библиотеки aiohttp на текущий момент таково, что с её использованием можно построить полноценный web-сервер в Джанго-стиле [4].

Для standalone-сервиса вся мощь не пригодится, поэтому создание сервера ограничивается несколькими строками.

Инициализируем веб-приложение:

async def init_app(loop):
    app = web.Application(loop=loop, middlewares=[])
    app.router.add_post('/api/v1', handler)
    return app

N.B. Обратите внимание, что здесь мы определяем роутинг и задаём обработчик входящих сообщений handler.

И стартуем веб-сервер:

app = loop.run_until_complete(init_app(loop))
web.run_app(app, host='0.0.0.0', port=23456)

Клиент


Для отправки сообщения используем метод sendMessage из Telegram API, для этого необходимо отправить на оформленный должным образом URL POST-запрос с параметрами в виде JSON-объекта. И это мы делаем с помощью aiohttp:

TOKEN = '111111111:AAHKeYAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
API_URL = 'https://api.telegram.org/bot%s/sendMessage' % TOKEN

...

async def handler(request):
    data = await request.json()
    headers = {
        'Content-Type': 'application/json'
    }
    message = {
        'chat_id': data['message']['chat']['id'],
        'text': data['message']['text']
    }
    async with aiohttp.ClientSession(loop=loop) as session:
        async with session.post(API_URL,
                                data=json.dumps(message),
                                headers=headers) as resp:
            try:
                assert resp.status == 200
            except:
                return web.Response(status=500)
    return web.Response(status=200)

N.B. Обратите внимание, что в случае успешной обработки входящего сообщения и удачной отправки «эха», обработчик возвращает пустой ответ со статусом HTTP 200. Если этого не сделать, сервисы Telegram продолжат в течение какого-то времени «дёргать» запросами хук, либо пока не получат в ответ 200, либо пока не истечёт определённое для сообщения время.

К содержанию

3. Что можно улучшить


Совершенству нет предела, пара идей, как сделать сервис функциональней.

Используем middleware


Допустим, возникла необходимость фильтровать входящие сообщения. Препроцессинг сообщений можно сделать на специальных веб-обработчиках, в терминах aiohtttp — это middlewares [5].

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

async def middleware_factory(app, handler):
    async def middleware_handler(request):
        data = await request.json()
        if data['message']['from']['id'] in black_list:
            return web.Response(status=200)
        return await handler(request)
    return middleware_handler

И добавляем обработчик при инициализации web-приложения:

async def init_app(loop):
    app = web.Application(loop=loop, middlewares=[])
    app.router.add_post('/api/v1', handler)
    app.middlewares.append(middleware_factory)
    return app

Мысли по поводу обработки входящих сообщений


Если бот будет сложнее, чем репитер-попугай, то можно предложить следующую иерархию объектов Api > Conversation > CustomConversation.

Псевдокод:

class Api(object):
    URL = 'https://api.telegram.org/bot%s/%s'

    def __init__(self, token, loop):
        self._token = token
        self._loop = loop

    async def _request(self, method, message):
        headers = {
            'Content-Type': 'application/json'
        }
        async with aiohttp.ClientSession(loop=self._loop) as session:
            async with session.post(self.URL % (self._token, method),
                                    data=json.dumps(message),
                                    headers=headers) as resp:
                try:
                    assert resp.status == 200
                except:
                    pass

    async def sendMessage(self, chatId, text):
        message = {
            'chat_id': chatId,
            'text': text
        }
        await self._request('sendMessage', message)


class Conversation(Api):
    def __init__(self, token, loop):
        super().__init__(token, loop)

    async def _handler(self, message):
        pass

    async def handler(self, request):
        message = await request.json()
        asyncio.ensure_future(self._handler(message['message']))
        return aiohttp.web.Response(status=200)


class EchoConversation(Conversation):
    def __init__(self, token, loop):
        super().__init__(token, loop)

    async def _handler(self, message):
        await self.sendMessage(message['chat']['id'],
                               message['text'])

Наследуя от Conversation и переопределяя _handler получаем кастомные обработчики, в зависимости от функциональности бота — погодный, финансовый etc.

И наш сервис превращается в ферму:

echobot = EchoConversation(TOKEN1, loop)
weatherbot = WeatherConversation(TOKEN2, loop)
finbot = FinanceConversation(TOKEN3, loop)

...

app.router.add_post('/api/v1/echo', echobot.handler)
app.router.add_post('/api/v1/weather', weatherbot.handler)
app.router.add_post('/api/v1/finance', finbot.handler)

К содержанию

4. Реальный мир


Регистрация webhook


Создаём data.json:

{
  "url": "https://bots.domain.tld/api/v1/echo"
}

И вызываем соответствующий метод API любым доступным способом, например:

curl -X POST -d @data.json -H "Content-Type: application/json" "https://api.telegram.org/botYOURBOTTOKEN/setWebhook"

N.B. Ваш домен, хук на который вы устанавливаете, должен резолвится, иначе метод setWebhook не отработает.

Используем прокси-сервер


Как говорит документация: ports currently supported for Webhooks: 443, 80, 88, 8443.

Как же быть в случае self-hosted, когда необходимые порты уже скорее всего заняты веб-сервером, да и соединение по HTTPS мы в нашем сервисе не настроили?

Ответ простой, запуск сервиса на любом доступном локальном интерфейсе и использование реверс-прокси, и лучше nginx здесь сложно найти что-то другое, пусть он возьмёт на себя задачу организации HTTPS-соединения и переадресацию запросов нашему сервису.

К содержанию

Заключение


Надеюсь, что работа с ботом через вебхуки не показалась сильно сложнее long polling, как по мне так даже проще, гибче и прозрачнее. Дополнительные расходы на организацию сервера не должны пугать настоящего ботовода.

Пусть ваши идеи находят достойный инструмент для реализации.

Полезное:


  1. Telegram Bot API
  2. 18.5. asyncio — Asynchronous I/O, event loop, coroutines and tasks
  3. aiohttp: Asynchronous HTTP Client/Server
  4. aiohttp: Server Tutorial
  5. aiohttp: Server Usage — Middlewares
Поделиться с друзьями
-->

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


  1. bitver
    18.02.2017 08:02
    +4

    Как, опять?..
    Да, но нет!

    Да и да…


    1. wirr
      18.02.2017 15:45

      Скорее нет, чем да. Статья качественнее написана и есть своя плюшка в виде aiohttp.


    1. tmnhy
      18.02.2017 16:02

      Телеграм очень опосредован в статье, скорее повод привлечь внимание тех, кто говорит, что хуки — это сложно и накладно.


  1. funnybanana
    18.02.2017 12:49
    +1

    Мой пример на php (чтобы разобраться в апи телеграмм потратил 5 минут):

    $api = "https://api.telegram.org/bot111111111:AAHKeYAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    // получаем данные
    $update = json_decode(file_get_contents("php://input"), TRUE);
    $message = $update["message"];
    // все команды
    $commands = array(
      "всем привет"    => "Дратути",
      "кто самый"    => "Без сомнения {$message['from']['first_name']} {$message['from']['last_name']}",
    );
    // не учитываем регистр
    $text_lower = mb_convert_case($message["text"], MB_CASE_LOWER);
    // искать команду будем по первым 200 символам.
    if (strlen($text_lower) > 200) $text_lower = substr($text_lower, 0, 200);
    // крутим текст сообщения от конца до 1го символа в поисках существующего ключа в $commands
    for ($i=0; $i < strlen($text_lower); $i++) { 
      $text_lower_new = substr($text_lower, 0, strlen($text_lower)-$i);
      if (isset($commands[$text_lower_new])) {
          $text_lower = $text_lower_new;
          break;
      }
    }
    // сам ответ
    if (isset($commands[$text_lower])) $answer = $commands[$text_lower];
    // отправляем ответ в чат
    if (!empty($answer)) file_get_contents("{$api}/sendmessage?chat_id={$message["chat"]["id"]}&text={$answer}");
    


    — при желании этот код можно сократить до 5 строк, но тогда его будет совсем сложно читать, поэтому не пойму тех кто хвастается кол-вом строк в коде.

    image

    На основе этого кода собрал бота который сидит у меня в рабочем чате и делает заметки, посмотреть на его работу можно добавив его: @jReminderBot


    1. Grogina
      19.02.2017 00:07
      +1

      все ещё пытаюсь понять логику перебора строки посимвольно, да еще и с конца, если
      а) одна из команд «здравствуйте» и базируясь на общепринятых правилах можно ожидать ее в начале строки, а не перебирать 200 символов.
      б) существует strpos($text, $command)


      1. funnybanana
        19.02.2017 08:52

        ну тут просто не очевидно на примере для чего это…
        ну вот в @jReminderBot я пишу "/save очень длинный текст" и вместо того что бы поставить условие:

        if (strlen($message['text']) >= 5 && substr($message['text'], 0, 5) == '/save') {
        // выполняем запись текста после "/save" в бд
        }
        

        — я ищу этот ключ в массиве перебирая строку с конца до начала:
        /save очень длинный текст
        /save очень длинный текс
        /save очень длинный тек
        /save очень длинный те

        /save оч
        /save о
        /save
        /save — бинго! этот ключ есть и дальше идёт перебрасывание на функцию.

        Таким способом после самой команды может быть любой длинны текст… Тот же FatherBot в телеграмм запрашивает параметр для команды отдельно — пишешь /setname, после он просит придумать новое имя и отправить следующим сообщением, в моём примере это бы выглядело следующим образом: "/setname Новое имя"

        При этом имя функции я так же вписываю в сам массив, примерно так это выглядит:

        $cmd = new Commands($message); // тут все функции
        
        $commands = array(
          "всем привет" => "Дратути",
          "/save" => "fun_saveText"
        );
        
        // проверки тут разные, в цикле перебор строки и т.д всё копипастить не буду.. в предыдущем комментарии это есть.
        
        if (strlen($commands[$text_lower]) > 3 && substr($commands[$text_lower], 0, 4) == 'fun_') {
          $fun_name = substr($commands[$text_lower], 4);
          if (method_exists($cmd, $fun_name)) {
            $answer = call_user_func(array($cmd, $fun_name));
          }else{
            $answer = "Функция {$fun_name} не найдена =(";
          }
        }else{
          $answer = $commands[$text_lower];
        }
        


        ну и согласитесь писать ключи в массиве гораздо удобнее и компактнее нежели всегда писать условие под каждую команду… Видимо нужно было сразу написать что не ставил цели писать обычный ехо-бот… Ну а сам массив у меня уже перекочевал в json формат, вынесенный отдельно в файл и там уже более 40 функций по мониторингу основного сайта и ещё пару шуточных функций и ответов…

        Я даже думал на хабре пост написать как на php этот бот пишется закрытыми глазами, но такие статьи уже не встречают восторгом тут…


        1. Grogina
          19.02.2017 10:19
          +1

          коллега, давайте еще раз,
          — в тексте "/save очень длинный текст" команда идет первой,
          — strpos() по религиозным причинам отпадает,
          но что мешает начать перебор не с конца Войны и Мир, а с начала строки? каким образом на это влияет хранение команд в массиве?


          1. funnybanana
            19.02.2017 15:37

            прошу простить, я с утра не совсем уловил суть вашего вопроса…
            что же, действительно тут вы правы:

            for ($i=1; $i < strlen($text_lower); $i++) { 
              $text_lower_new = substr($text_lower, 0, $i);
              if (isset($commands[$text_lower_new])) {
                  $text_lower = $text_lower_new;
                  break;
              }
            }
            


            — так мы из "/save очень длинный текст" будем двигаться от 1го символа до последнего (предварительно ограничив длину до 200 символов)

            /
            /s
            /sa
            /sav
            /save — бинго, ключ найден. И вместо того что бы проверять ключ 21 раз, мы проверили его 5 раз… Что в 4 раза быстрее на данном примере.

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

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

            P.S
            if (strpos($text_lower, "/save") == 0) {
            // действие
            }
            


            всё же условие, занимает значительно больше места нежели ключ в массиве… А вот то что я вместо strpos использую substr этому нет объяснения, видимо сонный был и затупил…

            P.P.S вот за что люблю хабр, что тут люди всегда посоветуют то что лучше, сейчас заменю substr на strpos и временно переключу перебор строки с первого символа, а когда появятся противоречия в командах, верну обратно перебор с конца. Grogina, вам спасибо.


            1. webdevium
              19.02.2017 16:18

              Ребята, вы конечно простите, но на дворе 2017 год.
              Чем вам регулярные выражения так в душу нагадили, что вы начали перебирать «Войну и мир»?
              Я понимаю и знаю, что они медленные, но в данном случае — это просто панацея от хламавелосипедов. Можно будет команды буквально в любую позицию строки писать.


              1. funnybanana
                20.02.2017 09:39

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


            1. Grogina
              19.02.2017 18:22

              даже если у вас их будет несколько сотен и вы хотите реализовать все в таком миксе текста и команд, ничто не мешает начать, бог с ним, перебор с позиции strlen($command_with_max_length)-1


  1. tmnhy
    18.02.2017 15:54

    поэтому не пойму тех кто хвастается кол-вом строк в коде

    Мне странно, что вы где-то увидели хвастовство.

    "...50 строк кода" в заголовке — это означает, что кода в статье немного и он не сложный. Не ищите подтекста там, где его нет.


    1. ainoneko
      20.02.2017 15:21

      Следующая статья будет называться «На 50 строк кодее»?


      1. tmnhy
        20.02.2017 15:36

        Ещё не решил, как вариант — «на 50 строк коже» или действительно «кодее»… Как правильно?