Django Telegram Bot
В данной статье я расскажу о том как написать Telegram бота с нуля. При этом, делая это не на коленке, а достаточно “по-взрослому”: имея “на борту” Django (для управления контентом, настройками бота, расписанием выполнения задач и пр.), Celery, python-telegram-bot в качестве обертки над Telegram API, Redis, PostgreSQL для хранения всего, что нужно боту. При этом для разработки используя Docker, а деплой в продакшн реализуя через Dokku с python buildback-ом от Heroku.
В качестве примера разработаем простенького бота, который порадует всех любителей поэзии: раз в сутки он будет присылать случайный стих, который будет парсить с одного из сайтов со стихами. Кроме этого, пользователи бота смогут по кнопке получать сколько угодно случайных стихов здесь и сейчас. Понравившиеся стихи смогут добавлять в избранное с возможностью последующего просмотра и прочтения.
Данный проект основан на потрясающем репозитории https://github.com/ohld/django-telegram-bot/ , код которого и был взят за основу для разработки бота на вышеописанном стеке.
Репозитории
Первым делом оставлю ссылки на репозитории, где все можно посмотреть, потыкать, клонировать и применить в своих проектах:
https://github.com/UNREALre/DjangoTelegramBot_Skeleton — чистый скелет приложения, подойдет для старта любого бота, которого вы планируете написать.
https://github.com/UNREALre/PoetryBot — финальный код нашего тестового поэтического бота.
Пощупать самого бота можно тут: http://t.me/poetry_soul_bot
Режимы работы бота
В работе Telegram ботов нет особых сложностей. Есть отличное API — https://core.telegram.org/bots/api , есть множество еще более отличных библиотек-оберток над этим API (я использую в проектах python-telegram-bot https://github.com/python-telegram-bot/python-telegram-bot) — можно брать и пользоваться. Но, прежде всего, необходимо понять несколько основных моментов. Одним из самых важных для понимания является режим работы боты.
Telegram поддерживает два режима работы:
- polling — концепция данного режима очень проста — мы со своей стороны раз в определенное количество времени (например, раз в секунду) шлем запросы на сервера Telegram, чтобы узнать, а не было ли со стороны пользователей каких-нибудь событий, если они есть — получаем их и как-то реагируем.
- webhook — полностью противоположная концепция. Со своей стороны мы не делаем вообще ничего, кроме того, что говорим Telegram о том, что вот ссылка (обязательно с HTTPS), по этой ссылке мы будем ждать обновлений. И далее, Telegram уже сам шлет нам по указанному адресу обновления, когда пользователь начинает взаимодействовать с ботом, что, безусловно, очень удобно.
Как легко догадаться, webhook-и намного более эффективны. Аналитики подсчитали, что, в среднем, 98.5% запросов в режиме polling-а происходит “вхолостую”, тогда как эффективность webhook-ов, само собой, равняется 100%, потому что обращение происходит тогда и только тогда, когда появляется событие для этого обращения.
Polling — быстрое идеальное решение для разработки и отладки, поэтому его мы и используем для этих целей.
Webhook — идеален для продакшена.
Регистрация бота
Первое с чего следует начать разработку бота — с его регистрации.
Для регистрации бота добавляем в Телеграм @BotFather — головного бота, где происходит регистрация новых ботов.
Пишем команду /newbot и задаем наименование нашего бота. В моем случае: “Поэтическая душа”.
После этого бот попросит ввести имя на латинском для никнейма бота, которое должно заканчиваться на _bot. В моем случае будет: poetry_soul_bot.
После этого бот выдаст информацию о том, по какой ссылке можно начать диалог с ботом и токен, который необходимо сохранить и добавить в .env файл проекта (об этом позже).
На этом — регистрация бота завершена. Можно переходить к разработке.
Структура проекта
Давайте рассмотрим из чего состоит наш проект, предварительно упомянув, что локальную разработку мы будем вести в Docker-е в режиме pooling-а, а деплоить на продакшн будем используя Dokku и webhook. Рассматриваем структуру скелетона (https://github.com/UNREALre/DjangoTelegramBot_Skeleton).
.env файлик содержит в себе переменные окружения, которые нужны нам для локальной разработки. При деплое на прод мы единожды при развертывании приложения пропишем несколько переменных окружения на сервере и более про это забудем. Основные переменные, которые нам понадобятся:
WEB_DOMAIN=http://localhost:8001 # Непосредственный домен бота
MEDIA_DOMAIN=http://localhost:8001 # MEDIA_DOMAIN — для доступа к медиа контенту, который может быть загружен в бота. Может совпадать с WEB_DOMAIN, а может быть иным, если используются сторонние хранилища контента.
#DJANGO
DJANGO_DEBUG=True # Для разработки ставим True
DJANGO_SUPERUSER_USERNAME=admin # задаем креды админа, который будет создан в entrypoint.sh
DJANGO_SUPERUSER_EMAIL=mymail@gmail.com
DJANGO_SUPERUSER_PASSWORD=test
#TG
TELEGRAM_TOKEN=token_from_bot_father # Токен, который мы получили от BotFather при регистрации бота
#DB
DB_USER=poetry_bot # креды БД
DB_USER_PASSWORD=test
DB_NAME=poetry_bot
DATABASE_URL=postgres://${DB_USER}:${DB_USER_PASSWORD}@db:5432/${DB_NAME}
POSTGRES_USER=poetry_bot
POSTGRES_PASSWORD=test
Dockerfile — докерфайл проекта, базовые директива для докера, применяться будет только для локальной разработки.
docker-compose.yml — тут описаны сервисы, которые будут подняты в контейнерах докером на локальной машине. По умолчанию это: db (PostgreSQL), redis, web, bot, celery, celery-beat (последние два нужны для периодических задач, которые, возможно, понадобится выполять нашему боту).
entrypoint.sh — последний файлик для локальной работы проекта, говорит докеру, что делать после того как поднимутся все контейнеры. Тут говорим, что надо сделать миграции, создать админа по кредам из .env файла, прогрузить различного рода фикстуры, если таковые имеются в проекте, собрать статику и запустить сервер.
Procfile — данный файл необходим для Dokku при деплое на прод. Он содержит одну или более строк описывающих типы процессов и ассоциированных им команд. При деплое приложения в Dokku будет создан Docker образ, затем Dokku извлечет файл Procfile и команды, которые в нем описаны, будут переданы в docker run. В нашем случае мы говорим, что необходимо сделать миграции, поднять gunicorn сервер, запустить celery воркера и celery beat.
requirements.txt — тут все понятно, список зависимостей нашего бота, которые будут ставится как при развертывании на локальной машине, так и при деплое на продашкн.
run_pooling.py — для кейса, когда приложение работает в pooling режиме (на локальном ПК).
runtime.txt — говорит Dokku, какую версию Python мы хотим использовать.
staticfiles — в данной папке вся статика проекта (админка).
media — по умолчанию используем данную папку для хранения медиа файлов. Предположим, мы из админки закачиваем какие-то картинки, которые надо будет показывать пользователям, вот они падают в данную папку потому, что в settings.py проекта прописано: MEDIA_ROOT = os.path.join(BASE_DIR, ‘media’)
logs — очень важная папка, потому что по логам мы будем отлавливать все, что происходит с ботом. В settings.py у нас задан логгер по умолчанию, который будет писать в файлик main.log все, что надо, ротируя его каждые 5 Мб.
dtb — головная папка Django проекта. Тут, скорее всего, ничего менять не придется, кроме settings.py.
tgbot — вся бизнес-логика бота находится в данной папке.
tgbot/admin.py — регистрирует модели приложения для использования в админке.
tgbot/forms.py — кастомная форма для массовой рассылки сообщений из админки.
tgbot/models.py — содержит все модели, используемые ботом. Как минимум для базовой работы необходимы модели:
- Config — для задания различных параметров бота в формате “параметр” — “значение”;
- User — для пользователей бота;
- Location, Arcgis — для работы с геокодированием, если будет надо боту;
- UserActionLog — фиксация действий пользователя.
tgbot/tasks.py — celery задачи, которые можно устанавливать на исполнение по расписанию в очень удобной форме в админке проекта благодаря django-celery-beat.
tgbot/utils.p — различные функции-хелперы.
tgbot/fixtures — здесь могут быть различные фикстуры с данными для моделей проекта.
tgbot/migrations — тут все понятно, миграции проекта.
tgbot/templates — кастомные шаблоны для админки.
tgbot/handlers/admin.py — модуль-обработчик событий для пользователей с правами администратора. Наша модель пользователей, описанная в модуле tgbot/models.py содержит возможность задавать различные права пользователям. Так вот, если пользователь — администратор, то можно описать для него тот или иной функционал, который будет реализовываться при вызове им команд бота. Логика как раз описывается в данном модуле.
tgbot/handlers/files.py — модуль-обработчик событий, связанных с отправкой боту файлов. Опять же, по-умолчанию, заточен на администраторов. Когда мы посылаем какой-нибудь файл боту (фото, видео, документ и пр) — файл сохраняется и ему присваивается уникальные ID. В последующем, если мы хотим отослать файл пользователю, можно в соответствующий API метод передавать полный URL до этого файла (например, на внешнем хранилище), а можно передать просто Telegram ID этого файла. Вот как раз тут описана функция, которая возвращает ID файла отосланного боту, если его отослал пользователь с правами администратора.
tgbot/handlers/handlers.py — головной модуль-обработчик всех пользовательских событий, которые могут возникнуть у пользователей в процессе взаимодействия с ботом. Логика большинства функций довольно простая: прилетает в метод два параметра — update и context, из которых можно извлечь все: id чата, информацию о пользователе, что отослано и т.п. Далее происходит та или иная бизнес-логика, после чего, либо вызывается api метод edit_message, либо send_message, либо какая-то другая отправка, либо ничего, в зависимости от нужд логики.
tgbot/handlers/utils.py — функции-хелперы для работы обработчиков. Базовый набор: функция отсылки пользователя действия “печатаю”, декоратор для логирования и обертка над api методом send_message с обработками исключений.
tgbot/handlers/commands.py — модуль-обработчик для команд бота. Чтобы не смешивать обычные обработчики действий (нажатия на кнопки, отправки сообщений, документов и пр) и обработчики команд — выносим их в отдельным модуль.
tgbot/handlers/keyboard_utils.py — модуль содержит функции генерации клавиатур. В огромном количестве случаев боту необходимо посылать пользователю сообщения вместе с клавиатурами, чтобы пользователь выбирал те или иные действия. Безусловно, можно все генерировать на месте, внутри модуля handlers.py (к примеру), но логичнее все вынести в единый модуль, отвечающий только за клавиатуры.
tgbot/handlers/manage_data.py — в данном модуле определяются переменные для параметра callback_data, задаваемого при использовании клавиатуры. Т.е. в keyboard_utils модуле мы создаем клавиатуры, которые состоят из кнопок, а у кнопок есть два главных параметра: текст и обратный вызов, т.е. какая строка будет отправлена боту, когда пользователь нажмет на кнопку.
tgbot/handlers/static_text.py — в данный модуль выносим все текстовые данные, которые используем в боте.
tgbot/handlers/dispatcher.py — наконец, пожалуй, ключевой модуль бота, в нем, в соответствии с названием, происходит диспетчеризация всех процессов бота. Во-первых, он содержит функцию запуска pooling-а для тестовых сред. Во-вторых, что самое главное, он содержит функцию setup_dispatcher, главная задача которой связать события, которые вызывает пользователь (например, вызов команды — CommandHandler, отправка сообщения — MessageHandler, нажатие на кнопку — CallbackQueryHandler и т.п.) с функционалом, который необходимо выполнить боту по данном событию.
Изучение кода
Теперь, имея представление о структуре нашего проекта, мы можем немного углубиться в код, чтобы понять как и что происходит.
Прежде всего, небольшая вводная. Разворачивая проект в Docker-е на локальной машине, не забывайте, что после того как внесли правки в код, необходимо перезагрузить контейнер с ботом. Т.е. вы делаете:
docker-compose up -d — build — в результате поднимутся все контейнеры.
Далее просматриваете их:
docker ps -a
И, найдя в списке контейнер с ботом пишите (когда это необходимо):
docker restart XXX , где XXX — ID контейнера.
Соответственно, если вы изменяете Celery-задачи, то не забывает перезагружать контейнер соответствующий для тестирования.
Модели проекта
Для нашего проекта, не считая стандартных моделей из скелетона, необходимо добавить две новых: для хранения полученных стихов и для хранения списка избранных стихов пользователем.
class Poem(BaseModel):
author = models.CharField(_('Автор'), max_length=1000)
header = models.CharField(_('Наименование'), max_length=1000)
text = models.TextField(_('Текст стихотворения'))
def __str__(self):
return f'{self.author} "{self.header}"'
class Meta:
ordering = ['author', 'header']
class Favourite(BaseModel):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='users')
poem = models.ForeignKey(Poem, on_delete=models.CASCADE)
def __str__(self):
return f'{self.user} - {self.poem}'
Головной файл с логикой
Модуль /tgbot/poetry.py содержит в себе класс, который описывает головную логику проекта. Давайте рассмотрим его.
import logging
import requests
from bs4 import BeautifulSoup
from random import randint
from typing import Iterable, Optional
from tgbot.handlers import static_text as st
from tgbot.models import Favourite, Poem, User
logger = logging.getLogger('default')
class Poetry:
source = 'https://www.culture.ru/literature/poems'
source_domain = 'https://www.culture.ru'
def __init__(self, user: User):
self.user = user
def load_poem(self, id: int = None) -> (str, Optional[int]):
"""Загружает стих.
Если передан id, то стих выбирается из базы, иначе - загружается случайный с сайта.
"""
result = ''
poem_id = None
try:
if id:
poem = Poem.objects.get(pk=id)
result = self.format_poem(poem)
poem_id = id
else:
# 1. Находим максимальную страничку со стихами
response = requests.get(self.source)
html = BeautifulSoup(response.text, 'lxml')
last_page = int(html.select('nav.pagination a')[-1].get_text())
# 2. Выбираем случайную страничку откуда будем брать сейчас стих
random_page = randint(1, last_page)
url = f'https://www.culture.ru/literature/poems?page={random_page}&limit=45&query='
response = requests.get(url)
html = BeautifulSoup(response.text, 'lxml')
poem_links = html.select('.card-heading_title-link')
# 3. Выбираем случайный стих
random_poem = randint(0, len(poem_links))
poem_link = poem_links[random_poem].get('href')
url = f'{self.source_domain}{poem_link}'
response = requests.get(url)
html = BeautifulSoup(response.text, 'lxml')
# 4. Готовим стих к сохранению в БД
author = html.select_one('.entity-heading_subtitle').get_text()
poem_name = html.select_one('.entity-heading_title').get_text()
poem_paragraphs = html.select('.content-columns_block p')
poem_text = []
for paragraph in poem_paragraphs:
poem_text.append(str(paragraph).replace('
', '\n').replace('', '').replace('', ''))
poem_text = "\n\n".join(poem_text)
poem_db, _ = Poem.objects.get_or_create(author=author, header=poem_name, text=poem_text)
poem_id = poem_db.id
result = self.format_poem(poem_db)
except Exception as ex:
logger.error(f'Ошибка в процессе загрузки стиха: {ex}')
result = f'{st.error}\n{ex}'
return result, poem_id
def add_to_fav(self, id: int) -> None:
"""Добавляет стих в избранное."""
poem = Poem.objects.get(pk=id)
Favourite.objects.get_or_create(user=self.user, poem=poem)
def get_authors(self, only_first_chars: bool = False, **kwargs) -> [str]:
"""Возврщает список авторов стихов из избранного пользователя.
Если передан флаг only_first_chars, то вернут только первые буквы в фамилиях автора.
"""
filter_by_first_char = kwargs.get('last_name_first_char')
filter = {
'user': self.user,
}
if filter_by_first_char:
filter['poem__author__contains'] = f' {filter_by_first_char}'
authors = Favourite.objects.\
filter(**filter).\
values('poem__author').\
order_by('poem__author').\
distinct()
if only_first_chars:
result = set()
for author in authors:
result.add(author['poem__author'].split()[1][0])
else:
result = [author['poem__author'].split()[1] for author in authors]
result = list(result)
result.sort()
return result
def get_poems(self, author: str) -> Iterable[Favourite]:
"""Получает автора. Возвращает стихи из избранного этого автора."""
return Favourite.objects.filter(user=self.user, poem__author__contains=author).order_by('poem__header')
def get_poem_by_id(self, poem_id: str) -> Poem:
return Poem.objects.get(pk=poem_id)
@staticmethod
def format_poem(poem: Poem) -> str:
"""Возвращает стих в Markdown-ready состоянии."""
return f'*{poem.header}*\n\n{poem.text}\n\n_{poem.author}_'
У класса есть два свойства — адрес источника и домен источника для последующей компоновки ссылок на стихи.
Инициализируя объект класса мы привязываем его к конкретному пользователю, который обратился за тем или иным функционалом.
Метод load_poem делает следующее: если в него передан id, то он просто выбирает из базы стих по id и возвращает его, если ничего не передано, то используя библиотеки requests и bs4, метод парсит источник со стихами выбирая случайный стих.
Метод add_to_fav получает id стиха, который пользователь хочет добавить в избранное и делает это.
Метод get_authors — нужен в нескольких местах нашего бота. Когда пользователь выбирает посмотреть содержимое избранное, то сначала бот показывает ему алфавитный список всех авторов, которые есть в избранном. Кликнув по букве, бот показывает уже полный список всех авторов на эту букву, наконец, кликнув на автора — пользователь получает список стихов. Так вот, данный метод либо просто возвращает всех авторов из избранного пользователя, либо список из первых букв фамилий авторов. В обоих случаях список отсортирован по алфавиту.
Метод get_poems — тут все просто, получает фамилию автора и возвращает все его стихи из избранного пользователя.
Метод get_poem_by_id — имеет красноречивое название.
Метод format_poem — статический, получает объект модели Poem и возвращает строку в разметке Markdown, которая будет красиво выводится ботом (название — жирным, автор — курсивом).
Как все взаимодействует?
Центральный модуль нашей логики — /tgbot/handlers/dispatcher.py, а именно его метод setup_dispatcher.
def setup_dispatcher(dp):
"""
Adding handlers for events from Telegram
"""
dp.add_handler(CommandHandler("start", commands.command_start))
# admin commands
dp.add_handler(CommandHandler("admin", admin.admin))
dp.add_handler(CommandHandler("stats", admin.stats))
dp.add_handler(MessageHandler(
Filters.animation, files.show_file_id,
))
# base buttons
dp.add_handler(CallbackQueryHandler(hnd.send_more, pattern=f'^{md.SEND_MORE}'))
dp.add_handler(CallbackQueryHandler(hnd.add_to_fav, pattern=f'^{md.ADD_TO_FAV}'))
dp.add_handler(CallbackQueryHandler(hnd.view_fav, pattern=f'^{md.VIEW_FAV}'))
dp.add_handler(CallbackQueryHandler(hnd.show_authors, pattern=f'^{md.AUTHOR_BTN}'))
dp.add_handler(CallbackQueryHandler(hnd.show_author_poems, pattern=f'^{md.POEMS_BY_AUTHOR}'))
dp.add_handler(CallbackQueryHandler(hnd.show_poem_by_id, pattern=f'^{md.POEM_BY_NAME}'))
dp.add_handler(CallbackQueryHandler(hnd.back_to_main_menu_handler, pattern=f'^{md.BUTTON_BACK_IN_PLACE}'))
return dp
Здесь происходит связка событий с нашими реакциями на события. Например, следующая строчка говорит о том, что мы хотим добавить в диспетчер обработчик команд (это, когда пользователь печатает слеш и что-то там), который при вводе команды /start вызовет соответствующую функцию command_start из модуля commands:
dp.add_handler(CommandHandler(“start”, commands.command_start))
А следующая строчка говорит о том, что мы хотим добавить в диспетчер обработчик нажатия на кнопку пользователем CallbackQueryHandler, который должен вызывать функцию send_more из модуля hnd, когда бот получит callback-команду SEND_MORE, описанную в модуле md:
dp.add_handler(CallbackQueryHandler(hnd.send_more, pattern=f’^{md.SEND_MORE}’))
И вот таким образом мы добавляем обработчики в диспетчер для всех возможных событий: команды, отправка сообщений, отправка картинок, видео, стикеров, нажатия на кнопки и т.п.
Теперь рассмотрим как бот реагирует на все это дело на примере функции send_more модуля hnd:
def send_more(update, context):
user_id = extract_user_data_from_update(update)['user_id']
user = User.get_user(update, context)
poetry = Poetry(user)
poem_text, poem_id = poetry.load_poem()
context.bot.edit_message_text(
text=poem_text,
chat_id=user_id,
message_id=update.callback_query.message.message_id,
reply_markup=kb.make_keyboard_for_start_command(poem_id),
parse_mode=telegram.ParseMode.MARKDOWN,
)
Как видим, функция получает два параметра — update и context.
Context — объект типа telegram.ext.callbackcontext.CallbackContext и он нам не особо интересен, т.к. в нашей функции все, что мы делаем, просто в конце с помощью этого объекта вызываем нужный нам мето: edit_message_text, send_message и т.п.
А вот update — интересный объект, ниже привожу все данные, которые прилетают в нем:
{
"update_id":13123123,
"callback_query":{
"id":"234234234234",
"chat_instance":"-35345345234",
"message":{
"message_id":321,
"date":1623867543,
"chat":{
"id":345345,
"type":"private",
"username":"JohnDoe",
"first_name":"John",
"last_name":"Doe"
},
"edit_date":1624207369,
"text":"Привет! Как дела? Добро пожаловать в нашу базу знаний!",
"entities":[
{
"type":"bold",
"offset":0,
"length":4
}
],
"caption_entities":[
],
"photo":[
],
"new_chat_members":[
],
"new_chat_photo":[
],
"delete_chat_photo":false,
"group_chat_created":false,
"supergroup_chat_created":false,
"channel_chat_created":false,
"reply_markup":{
"inline_keyboard":[
[
{
"text":"-U-",
"callback_data":"KNOWLEDGE_BASE#U"
},
{
"text":"А",
"callback_data":"KNOWLEDGE_BASE#А"
},
{
"text":"Б",
"callback_data":"KNOWLEDGE_BASE#Б"
},
{
"text":"И",
"callback_data":"KNOWLEDGE_BASE#И"
},
{
"text":"К",
"callback_data":"KNOWLEDGE_BASE#К"
},
{
"text":"Л",
"callback_data":"KNOWLEDGE_BASE#Л"
},
{
"text":"О",
"callback_data":"KNOWLEDGE_BASE#О"
}
],
[
{
"text":"П",
"callback_data":"KNOWLEDGE_BASE#П"
},
{
"text":"С",
"callback_data":"KNOWLEDGE_BASE#С"
},
{
"text":"Т",
"callback_data":"KNOWLEDGE_BASE#Т"
},
{
"text":"У",
"callback_data":"KNOWLEDGE_BASE#У"
},
{
"text":"Ф",
"callback_data":"KNOWLEDGE_BASE#Ф"
},
{
"text":"Ц",
"callback_data":"KNOWLEDGE_BASE#Ц"
},
{
"text":"Э",
"callback_data":"KNOWLEDGE_BASE#Э"
}
],
[
{
"text":"Я",
"callback_data":"KNOWLEDGE_BASE#Я"
}
],
[
{
"text":"Вернуться",
"callback_data":"IN_PLACE_BACK"
}
]
]
},
"from":{
"id":234234234,
"first_name":"some_test_bot",
"is_bot":true,
"username":"some_test_bot"
}
},
"data":"KNOWLEDGE_BASE#А",
"from":{
"id":345345,
"first_name":"John",
"is_bot":false,
"last_name":"Doe",
"username":"JohnDoe",
"language_code":"ru"
}
}
}
Как видно, в update-е содержится избыточная информация по всему, что нас может интересовать: кто написал, кому написал, что написал, какая была клавиатура, все даты и времена, какая кнопка была нажата и т.п.
Как уже очевидно с помощью этих параметров мы можем узнать всю интересующую нас информацию. Например, id чата для последующего ответа, можем получить всю информацию по пользователю, а в финале, вызвать метод, скажем, edit_message_text, как в примере выше, в который передается сообщение ответа, id чата, id сообщения, клавиатура (если нужна) и указывается режим парсинга сообщения — в данном случае мы используем режим Markdown, где, скажем, для выделения жирным используются звездочки: *жирный текст*, для курсив подчеркивания: _курсив_ и т.п. Можно использовать HTML режим, но имейте ввиду, что Telegram поддерживает крайне малое количество html тегов для разметки — https://core.telegram.org/bots/api#formatting-options
В ряде случаев нам необходимо передать не просто некий callback от пользователя, но еще и тот или иной его выбор. Предположим, если речь идет о клавиатуре с алфавитом. В функции обработчике нам необходимо знать, какую буквы выбрал пользователь. Мы, конечно, можем для каждой буквы написать свой обработчик, но это будет ужасно. Поэтому, можно поступить проще. Посмотрим пример формирования клавиатуры для такого случая:
InlineKeyboardButton(out_char, callback_data=f’{md.AUTHOR_BTN}#{cur_char}’)
Как видно, мы указываем в callback_data не просто фиксированное значение, а указываем еще после символ решетки и текущую букву кнопки. После чего, в диспетчере мы определяем обработчик события как всегда, обычном способом:
dp.add_handler(CallbackQueryHandler(hnd.show_authors, pattern=f’^{md.AUTHOR_BTN}’))
А вот уже в функции обработчике получаем доступ к тому, что было передано после решетки вот так:
def show_authors(update, context):
user_id = extract_user_data_from_update(update)['user_id']
user = User.get_user(update, context)
query = update.callback_query
query.answer()
query_data = query.data.split('#')
selected_char = query_data[1]
poetry = Poetry(user)
authors = poetry.get_authors(only_first_chars=False, last_name_first_char=selected_char)
context.bot.edit_message_text(
text=st.choose_author_full,
chat_id=user_id,
message_id=update.callback_query.message.message_id,
reply_markup=kb.make_authors_keyboard(authors),
parse_mode=telegram.ParseMode.MARKDOWN,
)
Как видно, мы получаем запрос из update.callback_query, а затем обращаемся к свойству data, которое и хранит нужную нам информацию, вычленяя из нее необходимую букву и производя необходимые манипуляции.
Реагировать на пользовательские сообщения можно, само собой, не только методом edit_message_text, можно отсылать новые сообщения через send_message, можно отсылать, скажем, видео файлы через send_video и т.п.
Dokku и его настройка
Что такое Dokku? Если вкратце и совсем просто Dokku — это open source Heroku, сделанный на базе Docker. Когда я прочел это определение, то следующим вопросом лично у меня был: “А что такое Heroku?”. Да, я слышал о Heroku неоднократно, но никогда не вдавался в подробности. Так вот, Heroku — это облачная PaaS (платформа как услуга)-платформа, поддерживающая множество языков программирования. Heroku, так же как и Dokku, обеспечивает быстрый и легкий деплой приложений “под ключ”: базы данных, логирования, мониторинг, контейниризация и т.д. и т.п. Heroku — модный и дорогой, Dokku — маленький, бесплатный и, возможно, не столь user friendly, но тем не менее, очень простой в изучении.
Можно сказать, что Dokku — это обертка над Docker, Heroku Buildpacks, Nginx и Git. Docker — обеспечивает Dokku контейнерами; Dokku использует свой собственный базовый образ Docker с ubuntu, всеми необходимыми пакетами, Heroku buildpack-ами и т.п. Heroku Buildpack — набор скриптов, задача которых определить, соответствует ли приложение заданному типу, скомпилировать и выпустить его. Билдпак, который Dokku запускает внутри контейнера, создает всю необходимую среду выполнения, устанавливает все зависимости и на выходе приложение готово к работе на 100%. Что касается Nginx, то Dokku передает внутрь приложения номер порта 5000, а для внешних использует порты 49200+, а трафик из/в контейнер проксирует Nginx, который нам не надо конфигурировать вообще никак, потому что Dokku все сделает сам. Наконец, в аспекте GIT-а тоже все очень удобно: Dokku использует git-hooks, отслеживает, когда код пушится в GIT и запускает скрипт, который и делает всю магию: создает docker образ из базового, запускает скрипт инициализации среды, запускает само приложение и рестартует Nginx.
Как я раньше разворачивал проекты, если не использовал Docker (хотя, с ним тоже были свои сложности)? Заход на чистый сервер, обновление пакетов, установка всех необходимых пакетов, создание пользователей, морока с правами, установка git, инициализация и пул проекта, установка virtual env, установка всех зависимостей проекта, установка python сервера и настройка, установка nginx и настройка для связи с Python сервером, установка БД и конфигурация приложения, установка redis, celery и их конфигурация, установка supervisor-а, который бы менеджил все это дело… В общем, очень много всяких муторных действий. Ниже я опишу как все это сделать буквально за пяток команд по 10–15 букв в каждой с использованием Dokku.
- Генерируем SSH ключ. Для этого пользуемся командой ssh-keygen (если у вас Windows, то делаем это в терминале git bash). Даем какое-нибудь осознанное имя файлам и запоминаем путь, куда мы их сохраним (это пригодится чуть дальше, когда будем настраивать GIT).
- Теперь нам надо установить Dokku. Как установить Dokku на хостинге со всей необходимой информацией в ручном режиме — можно прочитать тут: https://dokku.com/docs/getting-started/installation/
В моем случае — я решил взять droplet на DigitalOcean-е с уже предустановленным Dokku. Как итог — за 5 кликов мышки на сайте DigitalOcean-а, получаем машину с ubuntu, dokku и всем необходимым для начала конфигурации. - Используя созданный в п.1 ключ — заходим на сервер и начинаем настройку Dokku.
- Работать в консоли с Dokku приятно и легко. Запоминать особо не нужно команды, мы просто пишем dokku и получаем список доступных команд. Пишем, скажем, dokku apps и получаем список доступных вызовов уже для приложений и так далее. Первым делом — создаем наше приложение. Для этого пишем:
dokku apps:create poetry — где poetry имя приложения. - Теперь, когда приложение создано, можно добавить в него все необходимые нам переменные окружения. Для этого пишем:
dokku config:set poetry TELEGRAM_TOKEN=ВАШ_ТГ_ТОКЕН WEB_DOMAIN=https://site.ru MEDIA_DOMAIN=https://site.ru DOKKU_LETSENCRYPT_EMAIL=ivanov@gmail.com DJANGO_DEBUG=False BUILDPACK_URL=https://github.com/heroku/heroku-buildpack-python.git#v191
Формат команды установки переменной окружения: dokku config:set APP_NAME var_name=var_value , но никто не мешает сделать так как сделал я выше — написать все переменные окружения в строку через пробел. Что мы тут установили?
TELEGRAM_TOKEN — это, конечно, самое важно. Токен нашего бота, который мы получили у BotFather, при регистрации.
WEB_DOMAIN и MEDIA_DOMAIN — в данном приложении нам не пригодятся, но для чего они нужны я описывал выше, рассказывая про настройки Django приложения.
DOKKU_LETSENCRYPT_EMAIL — тут мы идем на опережение и сразу задаем емейл в данной переменной, она пригодится нам, когда мы будем настраивать SSL сертификат на домене (Telegram работает только по HTTPS, при использовании webhook-ов).
DJANGO_DEBUG — само собой в False, это же продакшн.
BUILDPACK_URL — а это интересная переменная, которая указывает Dokku, что для билда не следует обращать внимание на Dockerfile, который присутствует в корне нашего репозитория, а следует использовать heroku buildpack.
Что вообще такое buildpack? Как видим со ссылки — все тянется к Heroku. Buildpack — это набор скриптов, которые в зависимости от языка программирования подтягивают и ставят все нужные зависимости, компилируют код и делают прочую черновую работу автоматически за вас. Подробнее можно почитать тут: https://devcenter.heroku.com/articles/buildpacks - Теперь подключим PostgreSQL к нашему приложению. Для этого, сначала установим соответствующий плагин следующей командой:
dokku plugin:install https://github.com/dokku/dokku-postgres.git postgres
Создаем postgres сервис:
dokku postgres:create pg_poetry
Связываем только что созданный сервис с нашим приложением:
dokku postgres:link pg_poetry poetry
После этой связки в окружении нашего приложения автоматически появится переменная DATABASE_URL, содержащая в себе настройки подключения к базе, в чем мы можем убедиться, написав команду:
dokku config:show poetry
Чтобы подключить к PostgreSQL извне, можно написать команду:
dokku config:show poetry
Cмотрим там на строку подключения к сервису (DATABASE_URL) и разбираем ее на составляющие:
[database type]://[username]:[password]@[host]:[port]/[database name]
Вспоминаем как мы назвали только что сервис postgres (в нашем случае — pg_poetry) или пишем команду, чтобы вспомнить:
dokku postgres:list
После это пишем команду:
dokku postgres:expose pg_poetry
Выведется порт на который будет переброшено подключение и можно с pgadmin-а подключаться к нашей базе, используя логин и пароль с config строки, распаршенной нами выше, а порт берем отсюда. В качестве IP адреса хоста базы — IP dokku сервера. - Пришла пора поставить Redis. Все делаем по аналогии с PostgreSQL. Сначала ставим плагин:
dokku plugin:install https://github.com/dokku/dokku-redis.git redis
Создаем сервис:
dokku redis:create rd_poetry
Привязываем сервис к нашему приложению:
dokku redis:link rd_poetry poetry
В результате манипуляций в нашем приложении появилась новая переменная окружения — REDIS_URL, которую мы можем использовать в настройках нашего Django приложение (уже использовали). - Пора и честь знать, точнее, пора залить код! Для этого — инициализируем GIT репозиторий:
dokku git:initialize poetry
Теперь возвращаемся на локальную машину и вспоминаем путь, куда мы сохранили наш SSH ключ из п.1.
Открываем нашу папку с проектом, идем в .git папку и открываем файл config, там прописываем строчку, которая скажет гиту, где искать ключ (выделена жирным):
[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
symlinks = false
ignorecase = true
sshCommand = ssh -i C:/Users/myuser/.ssh/id_rsa_poetry
Если до этого в докку был добавлен другой ключ, то добавляем текущий через dokku ssh-keys:add
После этого идем в PyCharm или в консоль, или куда вам удобно и добавляет remote:
git remote add dokku dokku@droplet_ip_goes_here:project_name_goes_here
Разберем строку: dokku@droplet_ip_goes_here:project_name_goes_here. dokku — имя пользователя, обязательно именно такое. После собачки — указываем IP адрес сервера с Dokku. После двоеточия — имя приложения, которое мы создали в Dokku, в моем случае: poetry.
Наконец, делаем git push. - Мы почти добрались до финала. Осталось добавить домен к приложению и поставить сертификат. Для начала, добавим домен:
dokku domains:add poetry mydomain.ru
Теперь, сделаем сертификат через lets encrypt. Сначала поставим плагин:
dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git
Пишем команду, которая сделает сертификаты и все сама пропишет в настройки nginx-а:
dokku letsencrypt:enable poetry - Сообщаем Telegram, чтобы будем работать через webhook и ожидать оповещений на нашем только что сконфигурированном домене просто открыв следующую
https://api.telegram.org/bot<TELEGRAM_TOKEN>/setWebhook?url=https://<YOURDOMAIN.COM>/super_secter_webhook/ - Финальный пункт. После того как все будет развернуто, мы можем зайти “внутрь” нашего приложения, как мы это делаем с докером просто написав:
dokku enter poetry web , где poetry — название приложения, а web — название сервиса в который мы входим (список сервисов можно посмотреть в файле Procfile в корне проекта).
Там мы можем создать админа для доступа к админке используя стандартные django команды:
python manage.py createsuperuser
Если потребуется почитать логи, то напишем:
dokku logs poetry -t
В рамках данного раздела с установкой и настройкой Dokku хотелось бы акцентировать еще на одном нюансе — работа со статикой. Если в нашем проекте все отлично и нам не о чем беспокоиться, потому что мы используем whitenoise , то в случае деплоя проекта, который не использует whotenoise, а хочет управлять статикой через nginx (хотя, даже официальная дока Heroku советует использовать whitenoise)— у нас возникнут небольшие проблемы. Дело в том, что хоть у нас и будет выполняться команда collectstatic, но nginx не будет знать, где находится наша статика — это проблема №1. Проблема №2 — статика будет внутри нашего контейнера с приложением, поэтому нам надо как-то смонтировать volume-ы, чтобы nginx имел доступ к статике проекта. Делается это все довольно просто. Ниже пара пунктов реализации:
- Для начала надо запомнить, что dokku располагает конфигурационный файл nginx-а по пути /home/dokku/your_app_name. Править файл мы не можем. Но почитав его содержимое мы видим, что он включает в себя все конфигурационные файлы, которые находятся по пути /home/dokku/your_app_name/nginx.conf.d — и это наш вариант. Но руками на сервере прописывать файлы не очень хочется, хочется прописать их в проекте в IDE и добавить в репозиторий, не так ли? Так. Для этого нам понадобится плагин dokku-supply-config. Что делает этот плагин? При деплое он ищет файлы, которые будут находиться в папке /.dokku вашего репозитория, брать их всех и копировать по пути /home/dokku/your_app_name. Установим плагин:
dokku plugin:install https://github.com/josegonzalez/dokku-supply-config.git supply-config - Создадим в нашем проекте папку /.dokku, а внутри папку nginx.conf.d, внутри файлик конфигурационный static.conf со следующим содержимым:
location /static {
autoindex on;
alias /home/dokku/your_app_name/staticfiles;
}
Запоминаем путь, который мы указали тут. Он понадобится нам, когда мы будем связывать volume-ы. Делаем коммит и пуш. - Теперь нам надо находясь на сервере прописать для dokku параметр, который прокинет volume со статикой внутри нашего контейнера
dokku docker-options:add your-django-app deploy “-v /home/dokku/appname/staticfiles:/app/appname/staticfiles”
То, что идет после двоеточия — папка со статикой внутри вашего контейнера с приложением. Тут может быть staticfiles как у меня, может быть static, может быть все, что угодно, смотря что вы сконфигурировали по статике в settings.py вашего django приложения. Чтобы узнать точно путь можно написать:
dokku enter your_app_name your_container_with_django_name
Выбрать там папку со статикой и написать:
pwd
А то, что слева от двоеточия — это тот самый путь из п.2., который мы запоминали, прописывая в static.conf для nginx. - Теперь запустим “редеплой”, т.е. перезагрузим наше приложение написав команду:
dokku ps:restart your_app_name
Бонусом к очень удобной и беспроблемной настройке теперь идет прекрасный и очень удобный деплой: вносите правки в ваш код, делаете комит — пуш, и во время пуша в dokku происходит ребилд проекта, установка всего необходимого, прогоны тестов (если они у вас есть) и сборка проекта. Невероятно круто и очень удобно.
Итоги
Ознакомившись и разобравшись с написанным, склонировав проект-скелетон, вы готовы начать писать своего бота. Мы изучили основы работы ботов, детально изучили структуру проекта, аспекты реализации функционала и удобные техники разворачивания решения на продакшне.
Ну а если вы не хотите со всем этим морочиться и хотите заказать разработку, то я, как член команды компании Webtronics готов вам в этом помочь. Заходите к нам на сайт, связывайтесь удобным образом и уверен, что мы сможем быть полезны друг другу 🙂
Ten articles before and after
data-rh=”true”>Telegram Bot for Lazy – Max Makhrov – Medium – Telegram Group
Домашняя бухгалтерия в telegram. Начало. – Telegram Group
Telegram bot in Go app. Notifications are important part of any… – Telegram Group
Monitorar suas aplicações na AWS usando o Telegram – Telegram Group
Example of a telegram bot in python without using specific libraries – Telegram Group
Stream Tweets to a Telegram channel – Telegram Group
Introducing zilWatchBot. We are proud to introduce zilWatchBot… – Telegram Group
How to create a Telegram bot with Swift using Vapor – Telegram Group
Written in collaboration with Sushil Shaw – Telegram Group
How I Connected with 10,000 people over a couple of weeks – Telegram Group