Привет! Меня зовут Александр, я руководитель бэкенд-разработки в KTS. Сегодня я покажу, как написать своего Телеграм-бота на основе asyncio и aiohttp. Мы не будем использовать ни aiogram, ни любые другие библиотеки, а напишем бота с нуля, чтобы немного познакомиться с асинхронным программированием, корутинами и некоторыми примитивами синхронизации. Что будет в статье: Основы ботоведения Echo-бот Архитектура бота Реализация схемы: poller.py worker.py Class Bot Остановка бота Заключение Бот — сущность в мессенджере.  Они есть практически везде: в Телеграме, ВК. Это программа, которая на основе определенных алгоритмов отвечает на сообщения пользователей. Задачи и направления самые разные: бот может просто присылать сообщения «С добрым утром!» А может, например, управлять участниками внутри чата.  Перед началом работы с ботом его нужно создать и познакомиться с 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 В ответ вы должны получить что-то подобное: {"ok":true,"result":{"id":2065163148,"is_bot":true,"first_name":"Metaclass","username":"metaclassbot","can_join_groups":true,"can_read_all_group_messages":false,"supports_inline_queries":false}} Шаг 3. Далее нужно научиться получать уведомления из Телеграма. Существует 2 способа: webhook — инициатором запроса выступает Телеграм. Когда пользователь пишет боту, Телеграм делает запрос на URL, который вы установите с помощью метода setwebhook. У этого метода есть недостатки: его трудно отлаживать, так как должен быть публичный адрес бота. Также на каждое сообщение выполняется HTTP-запрос, и при 1000+ сообщений в секунду серверы не справятся. long polling — инициатором является ваше приложение. Оно обращается к Telegram API и получает уведомления или ожидает, если уведомлений нет — отсюда название long. Мы для получения уведомлений будем использовать long polling. Описание метода. Выполните в браузере такой запрос:https://api.telegram.org/bot<token>/getUpdatesОн завершится моментально и вернет:{"ok":true,"result":[]} Теперь выполните другой запрос:https://api.telegram.org/bot<token>/getUpdates?timeout=30 Он будет висеть 30 секунд и, если ничего не написать боту, вернет: {"ok":true,"result":[]} Если написать боту хоть что-то, он моментально вернет Update. Шаг 4. Теперь давайте научимся отправлять сообщение пользователю в ответ. Сначала отправим сообщение боту, для чего используем метод sendMessage. Метод принимает два обязательных параметра: chat_id — поле, которое пришло в объекте Update. Может быть как id персонального чата, так и id группового чата. text Выполните в браузере запрос. chat_id нужно получить из предыдущего запроса: https://api.telegram.org/bot<token>/sendMessage?chat_id=85364161&text=hello.  В результате бот напишет вам сообщение hello. Для работы с методами Telegram API будем использовать класс TgClient.  Для начала давайте получим сообщения из Telegram. Для этого нужно вызвать метод get_updates класса TgClient: Обратите внимание, что код получает токен бота из переменной окружения, поэтому перед запуском нужно установить значение переменной BOT_TOKEN . При запуске кода может быть два исхода: Если боту отправляли сообщение не более 24 часов назад, метод get_updates вернет все последние сообщения. Чтобы было проще, далее по тексту я буду использовать термин «новые сообщения», а не «новые объекты update». {'ok': True, 'result': [{'update_id': 503972397, 'message': {'message_id': 335, 'from': {'id': 85364161, 'is_bot': False, 'first_name': 'Alexander', 'last_name': 'Opryshko', 'username': 'alexopryshko', 'language_code': 'en'}, 'chat': {'id': 85364161, 'first_name': 'Alexander', 'last_name': 'Opryshko', 'username': 'alexopryshko', 'type': 'private'}, 'date': 1644871206, 'text': '1'}}]} Если боту не писали вообще, или писали давно, он зависнет на 5 секунд (timeout=5) и вернет пустой список: {'ok': True, 'result': []} при этом, если боту написать во время ожидания, он моментально вернет результат, как и в первом случае. Чтобы получать сообщения из Телеграма постоянно, в цикле нужно вызывать get_updates, так как он завершается сразу после написания боту нового сообщения. Но если запустить такой код, мы будем получать последние сообщения из Телеграма в бесконечном цикле. А чтобы получать только новые, нужно использовать параметр offset. Правило Телеграма: после получения новой пачки сообщений нужно взять из ответа параметр update_id и следующий запрос выполнять с offset на единицу больше, чем последнее сообщение, пришедшее из get_updates.  Теперь можно завершить написание полноценного echo-бота: В функции заменили get_updates на get_updates_in_objects, потому что гораздо удобнее оперировать объектами, чем словарями. Текущая реализация имеет большой недостаток — бот не работает параллельно: после получения обновления он сразу начинает выполнять бизнес-логику бота. В нашем случае он отправляет echo-сообщение, и в это время новые сообщения от бота получаться не будут. Получается, другие пользователи простаивают. Нужно как-то организовать параллельную обработку пользователей и обеспечить возможность масштабирования. Поэтому мы пойдем другим путем. Введем сущность poller. Он будет получать сообщения из Телеграма и ставить их в очередь, никакую бизнес-логику он не реализует. Он должен быть в единственном экземпляре. Введем сущность worker. Он будет выполнять все рабочие задачи. worker берет задачу из очереди и каким-то образом выполняет ее. Сущностей worker может быть много: Такая схема лучше изначальной по двум причинам: при возрастании нагрузки мы можем соответственно увеличить количество worker;  сообщения от пользователей обрабатываются параллельно. 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, тоже запущена в этом loop 2. Если в запущенной в фоне корутине create_task происходит исключение, мы можем не увидеть его сразу, только после остановки event loop. Из-за этого могут возникнуть сложности в нахождении ошибок. Почитать подробнее в этой статье.  У worker есть несколько кардинальных отличий от poller: для обеспечения параллельной обработки входящих сообщений запускается несколько _worker, а количество параллельных воркеров регулируется параметром  concurrent_workers ; новые сообщения приходят не из Telegram, а из очереди, которую предварительно заполнил poller; внутри handle_update — который запускается при появлении нового сообщения в очереди — реализуется бизнес-логика обработки сообщения, т.е. бизнес-логика бота. Теперь добавим запуск worker в корутину async def start. Обратите внимание, что очередь должна быть общая между poller и worker: На этом минимальная реализация бота готова, но есть нюансы, которые стоит улучшить: в корутине start мы оперируем внутренними компонентами бота: очередью, poller, worker. Было бы хорошо иметь сущность Bot с одним методом “start”; при остановке бота он завершается моментально, не дожидаясь выполнения запущенной логики бота. Поэтому может быть такое, что мы прервем пользовательский сценарий на середине. Всю работу с компонентами бота вынесем в отдельный класс Bot: И перепишем функцию run: Код запуска стал чище, теперь не нужно думать про внутренние компоненты бота. Достаточно запустить bot.start(), и бот начнет функционировать Сейчас остановка происходит при нажатии Ctrl + C. Возникает исключение KeyboardInterrupt, которое мы ловим и молча завершаем работу бота: Почему нужно делать красивое завершение (graceful shutdown): 1. Бизнес-логика бота может прерваться посередине, и для пользователя это будет выглядеть багом. Пример: пользователь отправил файл боту, бот отправил сообщение, что файл загружается, и в этот момент его остановили. Бот будет загружать файл вечно, а пользователь останется в недоумении.2. Если в боте есть подключения к другим компонентам, например, к базе данных или очереди, их нужно корректно завершать. Поэтому введем функцию stop в Bot и каждый внутренний компонент. Она будет отвечать за корректное завершение. Начнем с poller. Чтобы отменить запущенную задачу, нужно вызвать у нее метод cancel. https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel Для этого введем переменную _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, а задача написания бота хорошо подходит для этих целей. Исходный код.  Другие наши статьи по бэкенду и асинхронному программированию для начинающих: Цикл статей «Первые шаги в aiohttp»: пишем первое hello-world-приложение, подключаем базу данных, выкладываем проект в Интернет Визуализация 5 алгоритмов сортировки на Python Разбираемся в асинхронности: где полезно, а где — нет? Другие наши статьи по бэкенду и асинхронному программированию для продвинутого уровня: Пишем свой Google, или асинхронный краулер с rate limits на Python Пишем Websocket-сервер для геолокации на asyncio 👉 Написать такого бота — одно из домашних заданий курса «Асинхронное программирование на Python для джуниор-разработчиков».   Управляющий партнер KTS