Как стать автором
Обновить

Асинхронный телеграм-бот с вебхуками на Heroku

Время на прочтение7 мин
Количество просмотров26K

Разрабатывая телеграм-бота для проведения викторины, я даже не задумывался каким количеством человек он будет использоваться и как оказалось очень зря …

Посредством этой статьи я поделюсь своим опытом разработки телеграм-бота для большого количества пользователей: разберу свои ошибки и шаги для их решения.

Одной из моих рабочих задач как программиста была автоматизация проведения викторины. Конечно, уже существуют специализированные бесплатные приложения, заточенные под эти задачи, но нужно было такое, в котором не было бы ничего лишнего, оно было всегда под рукой и такое привычное, чтобы не нужно было с ним разбираться. Выбор пал на телеграм бота и для того, чтобы он справлялся с большей нагрузкой было принято решение использовать асинхронную библиотеку aiogram.

Начнём с создания эхо бота на aiogram, тут нет ничего сложного, возьмём пример из документации:

import logging

from aiogram import Bot, Dispatcher, executor, types

# Токен, выданный BotFather в телеграмме
API_TOKEN = 'BOT TOKEN HERE'

# Configure logging
logging.basicConfig(level=logging.INFO)

# Initialize bot and dispatcher
bot = Bot(token=API_TOKEN)
dp = Dispatcher(bot)

@dp.message_handler()
async def echo(message: types.Message):
    await message.answer(message.text)


if __name__ == '__main__':
    executor.start_polling(dp, skip_updates=True)

Однако преимущество aiogram над  python-telegram-bot и pyTelegramBotAPI в том, что он асинхронный, а значит может обрабатывать несколько запросов почти единовременно. Стандартная база данных sqlite отлично подходит для несложных проектов и уже входит в стандартную библиотеку питона, поэтому для начала я решил использовать её.

Через несколько часов работы приложение было написано, и мы с коллегами решили протестировать на себе его работоспособность. Бот запускался с использование технологии long polling, и запускался он на локальном компьютере. Для небольшого количества человек этого вполне достаточно: 3-4 человека в секунду бот выдерживает без особых проблем.

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

Решением этой проблемы стал переход на вебхуки. И, для обеспечения бесперебойной работы, разместим его на удалённом сервере. Отличным решением для этого является heroku: здесь можно управлять запуском приложения как с компьютера, так и с мобильного приложения, отслеживать логи и, что является наиболее важным для нас, настраивать вебхуки.

Алгоритм, для реализации эхо бота в данном случае занимает больше времени, но он достаточно прост:

1)    Регистрируемся на сайте https://dashboard.heroku.com/

2)    На странице Personal создаём новое приложение

Выбираем имя нашего приложения (у меня это «aiogram-echo-bot-webhook» - запомним его, оно нам ещё понадобится!), меняем сервер на Europe и нажимаем кнопку «create app»

Отлично, мы подготовили контейнер для нашего приложения! Передать туда код самого приложения можно несколькими способами, например через Heroku CLI или через GitHub. Разберём деплой через гитхаб, так как при любой возможности лучше использовать контроль версий :)

Перед деплоем на Heroku хорошо бы переписать наше приложение на вебхуки:

import logging
import os
from aiogram import Bot
from aiogram.dispatcher import Dispatcher
from aiogram.utils.executor import start_webhook
from aiogram import Bot, types


TOKEN = os.getenv('BOT_TOKEN')
bot = Bot(token=TOKEN)
dp = Dispatcher(bot)

HEROKU_APP_NAME = os.getenv('HEROKU_APP_NAME')

# webhook settings
WEBHOOK_HOST = f'https://{HEROKU_APP_NAME}.herokuapp.com'
WEBHOOK_PATH = f'/webhook/{TOKEN}'
WEBHOOK_URL = f'{WEBHOOK_HOST}{WEBHOOK_PATH}'

# webserver settings
WEBAPP_HOST = '0.0.0.0'
WEBAPP_PORT = os.getenv('PORT', default=8000)


async def on_startup(dispatcher):
    await bot.set_webhook(WEBHOOK_URL, drop_pending_updates=True)


async def on_shutdown(dispatcher):
    await bot.delete_webhook()


@dp.message_handler()
async def echo(message: types.Message):
    await message.answer(message.text)


 if __name__ == '__main__':
    logging.basicConfig(level=logging.INFO)
    start_webhook(
        dispatcher=dp,
        webhook_path=WEBHOOK_PATH,
        skip_updates=True,
        on_startup=on_startup,
        on_shutdown=on_shutdown,
        host=WEBAPP_HOST,
        port=WEBAPP_PORT,
    )

Что здесь происходит?

TOKEN, HEROKU_APP_NAME – мы считываем из переменных окружения, которые скоро добавим в наш проект.

WEBHOOK_HOST – доменное имя нашего приложения

WEBHOOK_PATH – часть пути, на который мы будем принимать запросы. Его следует придумать таким, чтобы не было возможности его угадать, во избежание фальсификации запросов. В нашем случае используется токен бота, так как его, также, следует держать в секрете.

WEBHOOK_URL – полный url адрес, на который будут принимать запросы.

WEBAPP_HOST – хост нашего приложения, оставляем локальный.

WEBAPP_PORT – порт, на котором работает наше приложение, так же считывается с переменных окружения, которое предоставляет Heroku, его мы не заполняем.

Асинхронная функция on_startup устанавливает вебхук для нашего телеграм бота, на который будут отсылаться уведомления о получении новых сообщений. И  on_shutdown, наоборот, удаляет этот вебхук при выключении.

Далее мы переключаем вывод логов только на вывод только чисто информативной информации. И, собственно, запускаем наш диспетчер, при этом при запуске опускаются все сообщения, которые были получены в то время, когда бот не работал, что указано в параметре «skip_updates».

Почти всё готово, но чтобы дать инструкции Heroku, как именно развернуть наше приложение, нужно создать файл «Procfile» и вставляем туда следующий код:

web: python main.py

Здесь: web – значит, что наше приложение будет web приложением, а то, что идёт после «:» это строка, которую необходимо выполнить в первую очередь. Запустить наш файл main.py с помощью питона.

И ещё один файл, который необходим для запуска, это requirements.txt, в котором мы указываем все зависимости нашего проекта. Его создаём, выполнив команду pip freeze > requirements.txt.

Также можно указать, какую конкретную версию питона использовать: для этого создадим файл «runtime.txt» и впишем туда версию питона по шаблону «python-3.9.7»

Теперь подготовим переменные среды на Heroku: для этого переходим на вкладку «Settings» и жмём кнопку «Reveal Config Vars»

Здесь добавляем два поля:

BOT_TOKEN – токен, полученный у BotFather

HEROKU_APP_NAME – имя приложения созданного на heroku, которое мы с вами запоминали.

Кликаем на кнопку «connect».

 

Для того, чтобы наше приложение обновлялось каждый раз, как мы заливаем новые изменения в ветку «master», можем нажать кнопку «Enable Automatic Deploys»

В первый раз, всё-таки придётся деплоить самим, для этого нажимаем кнопку ниже:

И дожидаемся окончания деплоя. При положительном результате, вывод будет, примерно таким:

Также можно посмотреть логи в верху окна кнопка «More»->«View logs»:

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

На этом можно было бы остановиться, эхо-бот готов, но в реальном проекте нам понадобится сохранять различные данные из приложения. Для этого нужна база данных, как и в прошлый раз мы можем воспользоваться стандартной sqlite, но так как мы используем асинхронную библиотеку, то и запросы в бд должны быть асинхронными. Поэтому устанавливаем библиотеку databases для sqlite: pip install databases[sqlite].

Разобьём код по модулям и подключимся к базе данных: создаём файл config.py и выносим туда все переменные (WEBHOOK_HOST, WEBHOOK_PATH и т.д.).

И ещё один модуль «db.py», в котором пишем следующий код:

from databases import Database
database = Database('sqlite:///bot.db')
# где bot.db – путь к файлу базы данных

Создадим таблицу:

CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id INTEGER NOT NULL,
text text NOT NULL
);

И дополняем модуль main.py:

async def save(user_id, text):
    await database.execute(f"INSERT INTO messages(telegram_id, text) "
                           f"VALUES (:telegram_id, :text)", values={'telegram_id': user_id, 'text': text})


async def read(user_id):
    messages = await database.fetch_all('SELECT text '
                                        'FROM messages '
                                        'WHERE telegram_id = :telegram_id ',
                                        values={'telegram_id': user_id})
    return messages


@dp.message_handler()
async def echo(message: types.Message):
    await save(message.from_user.id, message.text)
    messages = await read(message.from_user.id)
    await message.answer(messages)

Здесь мы после получения сообщения сохраняем его в базу данных, и затем просто возвращаем все сообщения, полученные от этого пользователя.

Не забываем обновить requirements.txt

Пушим всё на гитхаб, процесс сборки можно посмотреть на вкладке «Activity»

Проверяем в боте: отправляем пару сообщений, бот возвращает нам список сохранённых в базу.

Казалось бы, всё хорошо, но вдруг произошла непредвиденная ошибка и приложение необходимо перезапустить: зайдём на вкладку «Resources»

Нажимаем на карандаш, жмём переключатель, для выключения приложения и подтверждаем «confirm».

Вновь включаем таким же способом и пробуем отправить боту сообщения:

Ужас, мы потеряли все данные! Но почему, ведь они хранятся в базе данных? Это происходит потому, что деплой происходит в изолированных контейнерах и при каждом новом запуске создаётся новый контейнер, а как мы помним исходный файл с бд у нас был пустым.

В нашем случае данные нужны будут и после выключения, поэтому нам нужна изолированная от приложения база данных. К счастью на Heroku, помимо множества приложений, можно бесплатно развернуть и базу данных, например postgres.

Переходим в «elements»

Выбираем «Heroku Postgres»

И устанавливаем

Выбираем бесплатный план и вводим имя приложения, для которого подключаем бд, для того чтобы потом мы могли считывать строку подключения с переменных среды:

Переходим в переменные среды нашего приложения и видим, что там появился ключ «DATABASE_URL», который мы и будем использовать для подключения.

Для подключения к бд postgresql, установим пакет databases[postgresql]: pip install databases[postgresql]. Создаём исходные таблицы, но синтаксис создания таблицы немного поменяется:

CREATE TABLE messages (
id SERIAL PRIMARY KEY,
telegram_id INTEGER NOT NULL,
text text NOT NULL
);
async def read(user_id):
    results = await database.fetch_all('SELECT text '
                                       'FROM messages '
                                       'WHERE telegram_id = :telegram_id ',
                                       values={'telegram_id': user_id})
    return [next(result.values()) for result in results]

Вновь обновляем requirements.txt и пушим на гит.

Дожидаемся окончания деплоя, если приложение не запущено, то запускаем его и отправляем проверочные сообщения боту:

Получаем сообщения, всё отлично! Как и в прошлый раз возникает непредвиденная ситуация, из-за которой приходится перезапускать приложение. Перезапускаем, отправляем ещё одно сообщение и …

Видим, что все данные сохранились в бд!

Вот всё и готово! А дальше всё зависит только от ваших предпочтений: для чего и под какие задачи разработать функционал бота, но вы можете быть уверенны, что его производительность будет на высоте, а данные надёжно сохранены.

Исходный код приложения размещён в репозитории: ссылка.

Теги:
Хабы:
+1
Комментарии11

Публикации

Истории

Работа

Data Scientist
63 вакансии
Python разработчик
141 вакансия

Ближайшие события

Weekend Offer в AliExpress
Дата20 – 21 апреля
Время10:00 – 20:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн