Всем доброго времени суток) Совсем недавно я пообещал подробную статью про разработку бота в ТГ на #Python с использованием библиотеки #aiogram. Пора рассказать и об этом)
WARNING!!! Материала значительно больше обычного.
Приблизительное время прочтения: 20 минут
Возможно, что это будет похоже на очередную статью на Хабре, но я постараюсь объяснить трудные моменты максимально простым языком. Если же что-то непонятно, Вы можете написать об этом в комментариях.
Идея
Как видно на скриншоте выше, оформление расписания не является практичным, поэтому я захотел сделать ТГ-бота, который позволял бы студенту проверить своё расписание без двух-трёх тонн лишней информации.
Всё началось с идеи, которую нужно было подкрепить подробным техническим заданием... Которого не было. Короче говоря, первая сборка делалась без ТЗ и результат был практически соответствующий. На данный момент код бота состоит из >1000 строк кода, что довольно-таки много для такого бота.
Техническое задание (к третьей версии)
Итак, для ясности поясню, что бот в ТГ может создавать виртуальную клавиатуру для упрощения взаимодействия пользователя с ним. Кроме этого, бот может выполнять любые иные операции, выполнять которые позволяют возможности языка программирования Python, в том числе и взаимодействовать с различными системами управления базами данных.
Чуть ниже Вы можете видеть два скриншота техзадания для третьей версии бота.
Эти схемы немного более полные, чем последующая реализация бота, в которой отсутствуют седьмой и восьмой курсы в интерфейсе, а так же подгруппы в БД.
Так как мне требовалась работа с базой данных, необходимо определить, что это будет за БД и какую использовать СУБД. В конечном счёте мой выбор пал на использование "православной" (в смысле привычной) системы управления базами данных #MySQL. Просто я уже работал с этой СУБД, так как она часто используется в базах данных различных сайтов.
В качестве программной реализации данной СУБД я применил MariaDB, которая работает на старом добром Manjaro Linux. Да-да, я не отрёкся от Linux-подобных систем)
Server version: 10.6.5-MariaDB Arch Linux
Сразу сделал основную настройку безопасности с использованием mysql_secure_installation, и полез в консоль свежей СУБД.
Там нужно было составить базу данных public, и сделать в ней 5 таблиц. Насколько мне известно, в реляционных БД можно организовать взаимосвязь между таблицами на уровне самой БД, но мне было лень разбираться с этим, поэтому я реализовал эту связь в программном коде бота.
Перейдём к коду
Для начала нужно определить структуру проекта, а именно файлы и папки, которые будут содержать полезные данные (то есть результаты работы программиста)
В моём случае всё будет максимально просто:
- Основной файл main.py
- Файл, отвечающий за работу с базой данных database.py
- Файл, отвечающий за отладку (и логи), что помогает быстро выяснить причину той или иной ошибки в работе бота debugs.py
- Файл с разметками статичных виртуальных клавиатур (динамические генерируются "на ходу" прямо в основном файле) rkm.py
- И файл конфигурации, где будет лежать список администраторов и прочие константы config.py
Кроме этого будет ещё несколько дополнительных файлов, среди которых есть README.md (описание проекта в формате Markdown), requirements.txt (список зависимостей для бота) и парочка файлов SQL для первой настройки БД.
Основная часть программного кода
Итак, начнём с основного файла (main.py). В его начале есть 17 строк импортов, которые я перечислять не стану, так как в процессе написания своих проектов Вы найдёте всю необходимую информацию по поводу них.
Для безопасности я использую переменные окружения, чтобы не выкладывать логпасы в репозиторий. Первым делом нужно инициализировать бота и объект для реагирования на сообщения.
Итак, тут я применил конструкцию для получения токена из переменной окружения, а для "слушателя" применил параметр storage=MemoryStorage(), что было необходимо для корректной работы машины состояний.
Чуть выше этого я сделал две группы состояний:
Эти состояния будут устанавливаться при надобности, а именно при переходах по розовым стрелкам (на схеме пользовательских интерфейсов, где [конечно же] не указаны устанавливаемые состояния). При переходах по зелёным стрелкам состояние будет сбрасываться до состояния "по умолчанию". При переходах по синим стрелкам состояния не меняются.
Дальше в коде расположено некоторое количество функций для решения конкретных задач. Всего их четыре, плюс они используют друг друга (без рекурсий).
В самом конце файла расположена часть кода для запуска слушателя:
Реакция бота на сообщения
Для реакции на входящие сообщения в коде используются асинхронные функции примерно такого вида:
Итак, строка 171 содержит хэндлер, или перехватчик сообщений. В скобках указан фильтр, ограничивающий область действия этого хэндлера. Хендлер реагирует на сообщение и вызывает функцию, идущую прямо после него. В данном случае это функция start_bot. В заголовке функции указан формальный параметр message, являющийся объектом сообщения. Этот объект содержит в себе информацию о содержании сообщения, данные об отправителе (имя, никнейм, ID в ТГ) и некоторые метаданные.
На строке 174 лежит вызов функции debug_log. Эта функция написана мной в отдельном файле, поэтому опишу я её чуть позже.
Строки 177 ~ 194 (Получение списка институтов из БД и создание виртуальной клавиатуры для простоты выбора пользователем) помещены под блок try для того, чтобы бот мог сообщить пользователю в случае какой-либо непредвиденной ошибки. Собственно, строки 195 ~ 198 отвечают за поведение бота в случае ошибки: выполняется сохранение информации об ошибке в файл (о чём чуть ниже расскажу подробнее), а пользователю отправляется сообщение о том, что бот не смог обработать запрос.
На строке 190 видно установку состояния select_institute из группы состояний StartSetting. Это изменит реакцию бота на следующее сообщение таким образом, чтобы выполнялась другая функция.
Сравним эти хэндлер и функцию с предыдущими. Тут хэндлер реагирует не на определённую команду, а на любое сообщение в состоянии StartSetting.select_institute. Кроме этого в заголовке функции появился новый формальный параметр — state, необходимость которого заключается в возможности сбросить его. Кроме этого, можно переключить состояние на следующее через обращение к группе состояний (именно поэтому порядок описания состояний в группе состояний очень важен).
Что касается самой функции, то тут также основные действия заключены в блок try и описано поведение бота в случае какого-либо исключения в блоке except. К основным действиям относим повторное получение списка институтов из БД, проверку наличия выбранного (это обязательно, так как пользователь может вручную ввести название другого института (да и вообще всё что угодно), так что "проверка на дебила" строго обязательна). Если институт есть в БД, то сохраним его в состояние и переключим состояние на следующее (ну, и пользователя оповестим об этом). Блок поведения в случае возникновения исключения ничем не отличается от предыдущей функции.
После этого следует функция для получения информации о выбранном курсе и выдачи виртуальной клавиатуры для выбора группы.
Дальше следует ещё одна функция, которая фактически ничем не отличается от предыдущих двух, кроме алгоритма действий. Но я считаю важным обратить внимание читателей на одну строку (строка 292 на скриншоте):
В данном случае мы не переключаем состояние на следующее, а просто завершаем его. В таком случае состояние полностью удаляется, а все данные, сохранённые в нём (название института и номер курса) также удаляются. Поэтому сохранить их важно перед завершением состояния.
Основная функция
Обратите внимание, что хэндлер у этой функции не имеет фильтров. Это значит, что он реагирует на любые сообщения, на которые не среагировали хэндлеры выше. Поэтому эту функцию следует располагать в самом конце кода, прямо перед конструкцией, скриншот которой я продублировал сразу же после этого абзаца.
Возвращаясь к основной функции, отмечу, что она не принимает на вход ничего, кроме объекта сообщения, но при этом способна выполнять наибольшее число возможных действий, выбор одного из которых зависит от сообщения пользователя. Шаблоны этих сообщений заранее прописаны в кнопках на виртуальных клавиатурах.
Так же стоит обратить внимание, что у метода message.answer есть дополнительные формальные аргументы, из которых я использовал лишь два: parse_mode и reply_markup. В первый из них я передаю константу ParseMode.MARKDOWN, чтобы сообщить интерпретатору, что это сообщение в формате Markdown. Тем временем reply_markup предназначен для передачи виртуальной клавиатуры (она передаётся вместе с сообщением от бота, а не отдельно).
Очень часто для стабильной работы лучше всё-таки обновить виртуальную клавиатуру, даже если Вы уверены, что в данный момент именно она и отображается у пользователя. Ситуации бывают разные, а я на третий день написания статьи заметил "чёрного лебедя". Одна девушка вместо нажатия кнопки "СТАРТ" или ввода соответствующей команды просто написала боту сообщение "1", а бот просто взял и поставил стандартную клавиатуру (непредвиденная ситуация, так как аккаунт этой девушки не был сохранён в БД, и соответственно не было привязки к группе, отчего бот ничем не мог ей помочь).
Поэтому при обычном ответе бота на сообщение я переустанавливаю обычную виртуальную клавиатуру, а при смене состояния я меняю пользователю клавиатуру на отдельную, сделанную специально под то состояние, которое установил прямо перед отправкой сообщения пользователю.
Файл для работы с базой данных
Итак, файл, в котором содержится класс для работы с БД и его методы... Тут я не вижу смысла подробно описывать каждый метод класса, так как их всего 17, не считая __init__(self).
В данном методе выполняется лишь подключение к серверу MySQL, или вывод информации об ошибке в логи и завершение процесса, если подключение не удалось.
Парочка методов (про чтение и запись)
На этом моменте я лучше продублирую структуру БД из ТЗ, чтобы не было надобности постоянно листать туда-сюда для понимания.
Итак, вернёмся к самому методу для получения списка институтов. Сначала (строка 207) мы готовим SQL-запрос:
SELECT -- Выбрать
DISTINCT -- без дубликатов
institute -- столбец "institute"
FROM -- из таблицы
public.groups -- название таблицы справа от точки. Слева — название БД, в которой лежит таблица.
На строке 208 мы посылаем запрос в БД и сохраняем ответ сервера в курсоре. Информацию извлекаем на строке 209, а потом перегоняем в другой список, чтобы получить именно список институтов, а не список списков по одному институту в каждом. В конце возвращаем полученный список институтов. Если в процессе возникла ошибка, то мы попробуем переподключиться к БД на строке 218 и попробуем повторить действие, ограничив рекурсию при помощи аргумента, хранящего в себе число ошибок.
Далее мы рассмотрим операцию записи в БД
Увы, но на скриншоте можно заметить, что я использовал аргументы в том виде, в котором они были переданы в метод. Это крайне не безопасно, так как не обеспечивает защиту от SQL-инъекций. Инструменты psycopg2 позволяют максимально просто экранировать все символы, которые могут нарушить безопасность данных на сервере баз данных. Однако я использую #pymysql , поэтому в моём случае стоит использовать собственные способы защиты (которые я добавлю чуть позже).
В данном методе фигурируют сразу три SQL-запроса, из которых будут выполнены лишь два: первый и один из оставшихся, в зависимости от результата выполнения первого.
SELECT -- Выбрать
id -- столбец "id"
FROM -- из таблицы
public.users
WHERE -- строки, в которых
id = {tg_id} -- поле id имеет значение {tg_id} (аргумент метода)
В качестве ответа от сервера MySQL приходит либо пустой список, либо список из одного кортежа, содержащего в себе ID пользователя в ТГ. Если же пользователь уже был в БД, то мы просто обновим соответствующую строку. В противном случае добавим новую.
Если мы производим запись в БД, то стоит применять метод commit (строка 296) объекта соединения (хотя, при использовании блока with (заголовок которого на строке 286) это не обязательно)
В остальном этот метод ничем не отличается от остальных методов данного класса.
В конце файла расположено создание объекта класса DataBase (и этот объект импортирован в основной файл): db = DataBase()
Отладка и логирование
Тут есть старая функция debug_log, которая просто выводит информацию в консоль. Чуть ниже расположен класс Logger, отвечающий за логирование в файл и чтение из него по запросу администратора.
Тут нет ничего серьёзного, но стоит иметь ввиду, что файл, открытый для дозаписи не является читаемым, поэтому для чтения из него его сначала нужно закрыть, а потом открыть заново в режиме чтения, получить нужную информацию, а потом снова закрыть и открыть в режиме дозаписи.
Файл с виртуальными клавиатурами
Итак, для создания виртуальной клавиатуры нужно сделать объект класса ReplyKeyboardMarkup и наполнить кнопками (объектами класса KeyboardButton). Именно эти два класса мы и импортируем в данный файл.
Итак, для создания кнопки мы создаём объект класса KeyboardButton со строкой, которая содержит текст предполагаемого сообщения и добавляем его в объект класса ReplyKeyboardMarkup при помощи метода add() или же row() для нескольких кнопок в одной строке.
Этот небольшой файл содержит пять статичных виртуальных клавиатур. Кроме них есть ещё клавиатуры, создаваемые "на ходу" в зависимости от содержимого базы данных.
И, наконец, последний файл *.py
Тут список институтов, список из списков списков групп, список администраторов, константы (номера параметров, используются для чтения и записи пользовательских настроек), текст сообщения с описанием пользовательских настроек и названия дней недели в трёх различных видах записи.
При этом списки с названиями институтов и групп я планирую удалить, так как перенёс их в базу данных (во второй версии они были в этом конфиге).
Дополнительные файлы
В этом файле написаны SQL-запросы, позволяющие без труда воссоздать исходную структуру базы данных (как сами таблицы, так и содержимое некоторых из них).
Остальное
Кроме этого, в проекте есть файл с описанием проекта и файл со списком зависимостей (для установки через "python -m pip install -r requirements.txt"), а также бэкап всего содержимого базы данных в такой же файлик формата SQL.
Финал
И вот... Спустя несколько дней работы над этим ботом я получил вот такой результат) Ещё есть, что доработать, исправить и улучшить, но текущий результат уже довольно хорош на текущий момент.
Чуть выше лежат несколько скриншотов, изображающих часть функционала бота (и средства администратора в комплекте)
Заключение
В общей сложности на создание и развитие рассмотренного кода было потрачено около квартала [года], но главной целью написания данной статьи я ставил раскрытие некоторых возможностей, заложенных в библиотеке aiogram (странно, что за всю статью я упоминаю это название лишь второй раз) и показать несколько примеров их применения. Также были затронуты некоторые конструкции работы с базами данных с использованием MySQL и библиотеки pymysql.
Если ещё возникли вопросы — добро пожаловать в комментарии, где можно обсудить всё, что касается темы данной статьи)
Обращение к аудитории
Вы могли заметить, что на канале долгое время не было нового материала. Но сейчас я буквально заряжен новой информацией. Мне есть, о чём рассказать Вам. В первую очередь это особенности синтаксиса языка запросов SQL, лазейки для применения SQL-инъекций и способы защиты от них. Кроме этого я бы хотел сделать несколько статей на тему основ языков программирования C/C++. Там и структуры алгоритмов (ветвление, циклы, операторы выбора и прочее), и особенности различных конструкций самих ЯПов. Дайте только знать об этом в комментариях)