Пишем асинхронного Телеграм-бота
Привет! Меня зовут Александр, я руководитель бэкенд-разработки в KTS.
Сегодня я покажу, как написать своего Телеграм-бота на основе asyncio и aiohttp. Мы не будем использовать ни aiogram, ни любые другие библиотеки, а напишем бота с нуля, чтобы немного познакомиться с асинхронным программированием, корутинами и некоторыми примитивами синхронизации.
Что будет в статье:
Основы ботоведения
Бот — сущность в мессенджере.
Они есть практически везде: в Телеграме, ВК. Это программа, которая на основе определенных алгоритмов отвечает на сообщения пользователей. Задачи и направления самые разные: бот может просто присылать сообщения «С добрым утром!» А может, например, управлять участниками внутри чата.
Перед началом работы с ботом его нужно создать и познакомиться с Telegram API.

Шаг 1. Сначала создаем бота с помощью BotFather внутри Телеграма:
https://core.telegram.org/bots#6-botfather
После создания вы получите сообщение:
Done! Congratulations on your new bot.
Use this token to access the HTTP API:
<token>
.
В сообщении будет токен, который нужно использовать для запросов в Телеграм.
Шаг 2. Выполните первый запрос к Telegram API по инструкции: https://core.telegram.org/bots/api#authorizing-your-bot
В ответ вы должны получить что-то подобное:
Шаг 3. Далее нужно научиться получать уведомления из Телеграма. Существует 2 способа:
webhook — инициатором запроса выступает Телеграм.
Когда пользователь пишет боту, Телеграм делает запрос на URL, который вы установите с помощью метода setwebhook. У этого метода есть недостатки: его трудно отлаживать, так как должен быть публичный адрес бота. Также на каждое сообщение выполняется HTTP-запрос, и при 1000+ сообщений в секунду серверы не справятся.
long polling — инициатором является ваше приложение.
Оно обращается к Telegram API и получает уведомления или ожидает, если уведомлений нет — отсюда название long.
Мы для получения уведомлений будем использовать long polling. Описание метода.
Выполните в браузере такой запрос:
https://api.telegram.org/bot<token>/getUpdates
Он завершится моментально и вернет:
Теперь выполните другой запрос:
https://api.telegram.org/bot<token>/getUpdates?timeout=30
Он будет висеть 30 секунд и, если ничего не написать боту, вернет:
Если написать боту хоть что-то, он моментально вернет Update.
Шаг 4. Теперь давайте научимся отправлять сообщение пользователю в ответ. Сначала отправим сообщение боту, для чего используем метод sendMessage. Метод принимает два обязательных параметра:
chat_id — поле, которое пришло в объекте Update. Может быть как id персонального чата, так и id группового чата.
Выполните в браузере запрос. chat_id нужно получить из предыдущего запроса: https://api.telegram.org/bot<token>/sendMessage?chat_id=85364161&text=hello.
В результате бот напишет вам сообщение hello.
Для работы с методами Telegram API будем использовать класс TgClient.
Echo-бот
Для начала давайте получим сообщения из Telegram. Для этого нужно вызвать метод get_updates класса TgClient :
Обратите внимание, что код получает токен бота из переменной окружения, поэтому перед запуском нужно установить значение переменной BOT_TOKEN .
При запуске кода может быть два исхода:
Если боту отправляли сообщение не более 24 часов назад, метод get_updates вернет все последние сообщения. Чтобы было проще, далее по тексту я буду использовать термин «новые сообщения», а не «новые объекты update».
Если боту не писали вообще, или писали давно, он зависнет на 5 секунд (timeout=5) и вернет пустой список:
при этом, если боту написать во время ожидания, он моментально вернет результат, как и в первом случае.
Чтобы получать сообщения из Телеграма постоянно, в цикле нужно вызывать get_updates , так как он завершается сразу после написания боту нового сообщения.
Но если запустить такой код, мы будем получать последние сообщения из Телеграма в бесконечном цикле. А чтобы получать только новые, нужно использовать параметр offset .
Правило Телеграма: после получения новой пачки сообщений нужно взять из ответа параметр update_id и следующий запрос выполнять с offset на единицу больше, чем последнее сообщение, пришедшее из get_updates .
Теперь можно завершить написание полноценного echo-бота:
В функции заменили get_updates на get_updates_in_objects , потому что гораздо удобнее оперировать объектами, чем словарями.
Текущая реализация имеет большой недостаток — бот не работает параллельно: после получения обновления он сразу начинает выполнять бизнес-логику бота. В нашем случае он отправляет echo-сообщение, и в это время новые сообщения от бота получаться не будут. Получается, другие пользователи простаивают. Нужно как-то организовать параллельную обработку пользователей и обеспечить возможность масштабирования.
Поэтому мы пойдем другим путем.
Архитектура бота
Введем сущность poller. Он будет получать сообщения из Телеграма и ставить их в очередь, никакую бизнес-логику он не реализует. Он должен быть в единственном экземпляре.
Введем сущность worker. Он будет выполнять все рабочие задачи. worker берет задачу из очереди и каким-то образом выполняет ее.
Сущностей worker может быть много:

Такая схема лучше изначальной по двум причинам:
при возрастании нагрузки мы можем соответственно увеличить количество worker;
сообщения от пользователей обрабатываются параллельно.
Реализация схемы
poller.py
poller в точности повторяет логику echo-бота, за исключением отправки echo-сообщения:
получает уведомления из Телеграма;
кладет сообщения в очередь.
Логика получения уведомлений описана в методе _worker , для запуска получения уведомлений нужно запустить именно его. Но просто вызвать await self._worker() не получится, потому что мы заблокируем основной поток выполнения, а нам еще нужно запустить worker, который будет вычитывать сообщения из очереди. Поэтому нужно запустить фоновую задачу с помощью asyncio.create_task.
Теперь рассмотрим, как запустить poller.
Так как бот должен работать бесконечно, то необходимо организовать бесконечный цикл. В echo-боте мы просто оставили while True , но в текущей реализации так сделать будет неудобно, поэтому лучше использовать метод запуска run_forever , предварительно положив все необходимые задачи в event loop с помощью метода create_task.
Документация по методу run_forever
Документация по методу create_task
Обратите внимание:
1. В коде poller используется asyncio.create_task , а при его запуске используется loop.create_task .
Отличие заключается в том, что мы явно указали, какой event loop нужно использовать в синхронной функции def run . В асинхронных функциях async def loop явно можно не указывать, потому что Python сам знает текущий цикл событий и прикрепляет задачу к нему. Функция, из которой запускается create_task, тоже запущена в этом loop2. Если в запущенной в фоне корутине create_task происходит исключение, мы можем не увидеть его сразу, только после остановки event loop . Из-за этого могут возникнуть сложности в нахождении ошибок. Почитать подробнее в этой статье.
worker.py
У worker есть несколько кардинальных отличий от poller:
для обеспечения параллельной обработки входящих сообщений запускается несколько _worker , а количество параллельных воркеров регулируется параметром concurrent_workers ;
новые сообщения приходят не из Telegram, а из очереди, которую предварительно заполнил poller;
внутри handle_update — который запускается при появлении нового сообщения в очереди — реализуется бизнес-логика обработки сообщения, т.е. бизнес-логика бота.
Теперь добавим запуск worker в корутину async def start. Обратите внимание, что очередь должна быть общая между poller и worker:
На этом минимальная реализация бота готова, но есть нюансы, которые стоит улучшить:
в корутине start мы оперируем внутренними компонентами бота: очередью, poller, worker. Было бы хорошо иметь сущность Bot с одним методом “start”;
при остановке бота он завершается моментально, не дожидаясь выполнения запущенной логики бота. Поэтому может быть такое, что мы прервем пользовательский сценарий на середине.
Class Bot
Всю работу с компонентами бота вынесем в отдельный класс Bot:
И перепишем функцию run :
Код запуска стал чище, теперь не нужно думать про внутренние компоненты бота. Достаточно запустить bot.start(), и бот начнет функционировать
Остановка бота
Сейчас остановка происходит при нажатии Ctrl + C. Возникает исключение KeyboardInterrupt , которое мы ловим и молча завершаем работу бота:
Почему нужно делать красивое завершение (graceful shutdown):
1. Бизнес-логика бота может прерваться посередине, и для пользователя это будет выглядеть багом.
Пример: пользователь отправил файл боту, бот отправил сообщение, что файл загружается, и в этот момент его остановили. Бот будет загружать файл вечно, а пользователь останется в недоумении.
2. Если в боте есть подключения к другим компонентам, например, к базе данных или очереди, их нужно корректно завершать.
Поэтому введем функцию stop в Bot и каждый внутренний компонент. Она будет отвечать за корректное завершение. Начнем с poller.
Для этого введем переменную _task, в которую сохраним объект созданной задачи, а в момент остановки poller вызовем cancel .
В случае с worker нельзя просто взять и вызвать cancel у всех запущенных задач:
нужно обработать все задачи, которые poller положил в очередь, иначе обновления из Телеграма просто потеряются;
нельзя прерывать обработку конкретной задачи, т.е. у запущенной задачи cancel вызвать нельзя.
Получается, нужно дождаться выполнения всех задач, которые находятся в очереди. Для такой задачи есть метод join .
https://docs.python.org/3/library/asyncio-queue.html#asyncio.Queue.join
Давайте разберемся, что мы сделали:
в методе start сохранили все запущенные задачи в self._tasks ;
внутри метода stop перед вызовом метода cancel у всех задач дождались, когда все задачи из очереди будут выполнены с помощью await self.queue.join() ;
с помощью self.queue.task_done() помечаем «выполненными» задачи внутри метода _worker .
В итоге получается:
остановили poller, новые задачи не добавляются в очередь.
внутри worker ждем, пока выполнятся все задачи (self.queue.join()) , и только после этого вызываем отмену воркеров.
так как все задачи завершились, а новые не поступают — poller остановлен — то можно вызывать cancel у задач и не бояться прервать бизнес-логику бота.
Теперь добавим в Bot метод stop :
И после возникновения исключения KeyboardInterrupt запустим остановку бота с помощью loop.run_until_complete.
https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.run_until_complete
Чтобы проверить, что мы все сделали правильно, можно добавить asyncio.sleep(10) в метод handle_update , отправить боту сообщение и попробовать завершить бота раньше, чем через 10 секунд. В итоге должна получиться подобная картина:

Заключение
Цель этой статьи — показать на примере, как работать с базовым асинхронным программированием и примитивами синхронизации asyncio, а задача написания бота хорошо подходит для этих целей. Исходный код.
Написать такого бота — одно из домашних заданий курса «Асинхронное программирование» в нашей школе Metaclass.
11-го апреля у нас стартует третий поток курса. Если хотите разобраться в этой теме, записывайтесь по ссылке выше.
Введение, простой echo-бот
Приветствую тебя, читатель! Telegram Bot API – это мощный инструмент для вообще чего угодно. Автоматизация действий, работа с пользователями, онлайн-магазины, игры и много чего ещё. В этом учебнике мы научимся писать ботов для Telegram на языке Python.
Сразу оговорюсь: тот бот, который получится в итоге — это лишь прототип, цель всех этих постов — рассказать об основах ботостроения, показать, как можно за короткое время написать простого бота для своих нужд.
Язык программирования будет Python 3, но это не означает, что любители PHP, Ruby и т.д. в пролёте; все основные принципы совпадают. Я не буду особо останавливаться на описании самого языка, желающие могут ознакомиться с документацией по Python здесь.
Подготовка к запуску
Взаимодействие ботов с людьми основано на HTTP-запросах. Чтобы не мучаться с обработкой «сырых» данных, воспользуемся библиотекой pyTelegramBotAPI, которая берет на себя все нюансы отправки и получения запросов, позволяя сконцентрироваться непосредственно на логике. Установка библиотеки предельно простая:
Теперь можно выйти из режима Python-консоли (Ctrl+Z или Ctrl+D, или exit() )
Пишем простого echo-бота
Ну, довольно слов, перейдем к делу. В качестве практики к первому уроку, напишем бота, повторяющего присланное текстовое сообщение. Создадим каталог, а внутри него 2 файла: bot.py и config.py . Я рекомендую выносить различные константы и настройки в файл config.py , дабы не загромождать другие. В файл config.py впишем:
Теперь надо научить бота реагировать на сообщения. Напишем обработчик, который будет реагировать на все текстовые сообщения.
У читателя возникнет вопрос: зачем там символ “@”? Что это вообще за message_handler ? Дело в том, что после приёма сообщения от Telegram его надо обработать по-разному в зависимости от того, что это за сообщение: текст “привет” или текст “пока”, может быть, вообще стикер или музыка. Первое, что придёт в голову – написать множество конструкций if-then-else , но такой подход некрасивый и позволяет быстро запутаться.
Для решения этой проблемы автор библиотеки pyTelegramBotAPI реализовал механизм хэндлеров, которые используют питоновские декораторы (пока просто запомним это слово). В хэндлере описывается, в каком случае необходимо выполнять ту или иную функцию. Например, хэндлер @bot.message_handler(content_types=[«text»]) выполнит нижестоящую функцию, если от Telegram придёт текстовое сообщение, а хэндлер @bot.message_handler(commands=[«start»]) сработает при получении команды /start.
Теперь запустим бесконечный цикл получения новых записей со стороны Telegram:
Функция infinity_polling запускает т.н. Long Polling, бот должен стараться не прекращать работу при возникновении каких-либо ошибок. При этом, само собой, за ботом нужно следить, ибо сервера Telegram периодически перестают отвечать на запросы или делают это с большой задержкой приводя к ошибкам 5xx)