Telegram-бот, webhook и 50 строк кода

Как, опять? Ещё один туториал, пережёвывающий официальную документацию от 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

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


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

Псевдокод:

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

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 19

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

    Да и да…
      0
      Скорее нет, чем да. Статья качественнее написана и есть своя плюшка в виде aiohttp.
        0
        Телеграм очень опосредован в статье, скорее повод привлечь внимание тех, кто говорит, что хуки — это сложно и накладно.
        +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
          все ещё пытаюсь понять логику перебора строки посимвольно, да еще и с конца, если
          а) одна из команд «здравствуйте» и базируясь на общепринятых правилах можно ожидать ее в начале строки, а не перебирать 200 символов.
          б) существует strpos($text, $command)
            0
            ну тут просто не очевидно на примере для чего это…
            ну вот в @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
              коллега, давайте еще раз,
              — в тексте "/save очень длинный текст" команда идет первой,
              — strpos() по религиозным причинам отпадает,
              но что мешает начать перебор не с конца Войны и Мир, а с начала строки? каким образом на это влияет хранение команд в массиве?
                0
                прошу простить, я с утра не совсем уловил суть вашего вопроса…
                что же, действительно тут вы правы:

                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, вам спасибо.
                  0
                  Ребята, вы конечно простите, но на дворе 2017 год.
                  Чем вам регулярные выражения так в душу нагадили, что вы начали перебирать «Войну и мир»?
                  Я понимаю и знаю, что они медленные, но в данном случае — это просто панацея от хламавелосипедов. Можно будет команды буквально в любую позицию строки писать.
                    0
                    что бы писать команду в любом месте строки можно и strpos или substr_count использовать. И опять же команды все удобно держать в одном месте, отделив его от кода. Поэтому и такое странное решение с ключами массива…
                    0
                    даже если у вас их будет несколько сотен и вы хотите реализовать все в таком миксе текста и команд, ничто не мешает начать, бог с ним, перебор с позиции strlen($command_with_max_length)-1
            0
            поэтому не пойму тех кто хвастается кол-вом строк в коде

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

            "...50 строк кода" в заголовке — это означает, что кода в статье немного и он не сложный. Не ищите подтекста там, где его нет.
              0
              Следующая статья будет называться «На 50 строк кодее»?
                0
                Ещё не решил, как вариант — «на 50 строк коже» или действительно «кодее»… Как правильно?
              0

              Читаю такие статьи и ни в одной не описано, зачем, используя вебхук, еще дополнительно дергать sendMessage? Ведь в webHook явно сказано, что вместо того, чтобы просто отвечать 200 — можно сразу ответить сообщением и оно уйдет в телеграм.


              Какие-то накладные расходы? Минусы? Поделитесь, пожалуйста?

                0
                Минусы?

                It's not possible to know that such a request was successful or get its result.

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

                1. Запускать из командной строки проще так. Хероку автоматически проксирует все запросы.
                heroku run curl -X POST "https://api.telegram.org/botTOKEN/setWebhook?url=HOST/api/v1"

                А ваш пример почему-то удалял вебхук.
                2. Также херока сам проставляет порт из переменной окружения:
                port=os.environ.get('PORT', 8080)

                Подробнее про деплой Питон приложений на Хероку тут
                Кому-то может быть полезным. У Хероки есть бесспорные плюсы:
                • Проксирование запросов к апи телеграмма
                • Бесплатно

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

                  Оооо, ваш комментарий через 3 года, говорит о том, что мысли оформленные в приступе прокрастинации оказались целостными и самодостаточными.
                  0
                  *

                  Only users with full accounts can post comments. Log in, please.