Telegram: @textUtilsBot
29.10.2018


Сегодня мы рассмотрим моего последнего (по крайней мере на данный момент) бота. Он существенно отличается от предыдущих технически и даже физически размещается на другом сервере. Но сперва расскажу, что он вообще из себя представляет.

Идея, развитие которой вылилось в создание бота, пришла в мою голову совершенно спонтанно во время одного из разговоров в чате ещё 26 августа 2017 года К сожалению, часть сообщений там удалена, так что точно проследить историю трудновато, но попробую рассказать по памяти. Всё началось с простой шутки: мне захотелось написать сообщение на языке компьютеров — в виде бинарного кода из нулей и единиц. Потом в виде шестнадцатеричных значений — это уже скорее язык низкоуровневых программистов =)

Да, всё началось с этаких шифровок. Разумеется, из-за такой фигни я не побежал сразу же пилить нового бота. Для возникновения пожара разработки, кроме топлива, нужны окслитель и источник огня. Окислителем стала моя любовь к смайлику ¯\_(ツ)_/¯ и постоянная неработоспособность бота @FailsBot, чей функционал по «пожиманию плечами» я впоследствии и скопировал. Источником искры, последней каплей, стал… русский язык. Вернее кириллица и привычка некоторых людей не проверять, на каком языке они пишут, перед набором сообщения. Вообще, история этой проблемы забавна, поскольку она настолько раздражающая, что я стал третьим человеком, который взялся за её решение.

Первым решить её пытался Рабу со своим ботом. Работал он следующим образом: во-первых, бота нужно было добавить в чат; во-вторых, на сообщение с неправильной раскладкой надо было ответить специальной командой. В результате бот выдавал правильный вариант текста. Минусов у такого подхода два: необходимость вручную вызывать команду и необходимость присутствия бота в чате, что не всегда бывает возможно.

Первую проблему попытался решить часто упоминаемый нынче в моих статьях товарищ KivApple. Его бот использовал словари русских и английский слов, читал все сообщения и для каждого высчитывал вероятность что слова в нём представляют собой какую-то чушь. Таким образом, бот автоматически присылал в чат сообщение в другой раскладке, если считал, что слов из исходного текста не существует в природе. На практике, однако, оказалось, что данный подход работает не очень хорошо и даёт много ложных срабатываний, не срабатывая в некоторых случаях с другой стороны. Так что в итоге эта идея провалилась и все пришли к выводу, что лучше человека с задачей определения неправильной раскладки никто не справится. На самом деле, конечно, можно придумать и опробовать ещё множество вариантов, но уже стало понятно, что на коленке с наскока эту задачу не решить. А заниматься какими-то дополнительными исследованиями ни у кого не оказалось ни желания, ни времени.

Мой бот, в свою очередь, обходит вторую проблему. Работая в inline-режиме, он определяет язык переданного ему текста (разумеется, поддерживаются только кириллица и латиница) и предлагает вариант с противоположной раскладкой, не делая никаких предположений. Да, немного запарнее для пользователя, так как надо копировать текст и вставлять в поле ввода. Зато не надо добавлять бота ни в какие чаты и даже можно просто посмотреть результат перевода во всплывающем окне, не отправляя сообщение в сам чат!

Итак, подведём итоги. Мне нужен был бот, работающий исключительно в inline-режиме, который смог бы «пожимать плечами», переводить текст в другую раскладку, в последовательность нулей и единиц или шестнадцатиричных байтов, а также обратно. В результате появилась первая версия, в которую вошло «пожимание плечами» и переводчик на компьютерно-программистские языки цифр. Перевод раскладки был добавлен «чуть» позже (подумаешь, всего-то через месяц =) ) вместе с функцией кодирования текста в base64-строку и обратно.

Последняя была придумана так же случайно, как и все остальные. Впрочем, любой человек может предложить мне добавить новую функцию трансформации текста. Для этого бот поддерживает две команды в личке:

КомандаПараметрыОписание
/help и /start нет выводит сообщение со справочной информацией
/suggest текст предложения, который будет отправлен разработчику бота позволяет предложить новый функционал для бота

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


Техническая часть

Теперь, покончив с историей и описанием бота, обсудим вопросы технической реализации. В отличие от предыдущих ботов, этот построен на основе библиотеки aiotg. В чём её отличие от pyTelegramBotAPI? Последняя под капотом использует библиотеку requests, которая упрощает выполнение синхронных блокирующих запросов. И в этом её недостаток: поток блокируется на время выполнения каждого сетевого обращения к API, что является довольно дорогой и длительной операцией (особенно в случае возникновения каких-либо проблем с сетью или долгим ожиданием ответа от сервера), во время которой программа зависает, ничего не делает и перестаёт отвечать на новые поступающие сообщения. Для решения данной проблемы pyTelegramBotAPI предлагает класс AsyncTeleBot. Он запускает каждый запрос в отдельном потоке, что, по заверению разработчиков библиотеки, может существенно ускорить бота, но надо понимать, что при необходимости выполнения нескольких последовательных запросов всё равно придётся блокировать поток до возвращения результата промежуточных запросов. В результате опять придётся городить новые потоки, чтобы не блокировать основной. А каждый новый поток — это новые накладные расходы по памяти и увеличение времени, затрачиваемого на переключение контекста… Да и, в конце концов, Пайтон не Джава и оптимизирован для максимальной производительности в однопоточной среде.

Конкретно для решения проблемы с вводом и выводом данных уже давно существует более элегантное решение — асинхронный I/O и модуль asyncio в стандартной библиотеке. Асинхронность не равна параллелизму, и в данном случае мы будем говорить о так называемой кооперативной многозадачности, когда следующая задача выполняется только после того, как текущая явно объявит себя готовой отдать процессорное время. Такие задачи называются корутинами (coroutines) и выполняются внутри специального планировщика, который в Пайтоне именуется event loop'ом — циклом событий. Задания отправляются на выполнение в планировщик, который запускает на выполнение какую-либо из них. Корутины могут запускать другие корутины и дожидаться их выполнения. Если внутри корутины происходит операция ввода/вывода, то её выполнение приостанавливается и управление переходит обратно в event loop, который ставит на выполнение следующую задачу. Внутри это всё работает на генераторах и функциях обратного вызова, но для конечного пользователя всё выглядит в виде няшных конструкций с async/await'ами.

Далее погружаться в эту тему я не хочу, потому что она реально прям очень обширная и сложная, чтобы уместить её в статье о боте. Да и, честно говоря, я сам не до конца понимаю все тонкости, чтобы суметь объяснить, как это всё работает. Главное, что стоит вынести из моего рассказа:

  • вместо синхронного блокирующего I/O используется асинхронный и неблокирующий
  • event loop вместе со всеми корутинами выполняется на одном потоке!

Таким образом достигается возможность обслуживания огромного множества легковесных корутин на одном потоке без лишних накладных расходов. И именно поэтому я решил отказаться от библиотеки pyTelegramBotAPI. Но вот с выбором асинхронной замены для неё оказалось не всё так просто.

На официальном сайте Telegram предлагается использовать библиотеку AIOGram. Как бы парадоксально это ни звучало, но проблема с ней в том, что она старается идти в ногу со временем и использует фишки последних версий Пайтона: официально в последней на данный момент версии 1.4 поддерживается Python 3.6, но надпись written in Python 3.7 на странице проекта в GitHub как бы намекает. Тут надо отметить, что модуль asyncio появился в Python 3.4, а ключевые слова async/await — в Python 3.5. И именно версия 3.5 стояла у меня на компьютере и на одном из серверов. При этом если установить новую версию на компьютер под управлением Windows — тривиальная задача, то для систем на базе Debian это каждый раз превращается в настоящий квест, когда приходится либо компилировать всё из исходников, разгребая попутно кучу граблей, либо хитрыми манипуляциями с настройками APT'а пытаться поставить более свежие версии Питона и всех зависимых пакетов, ничего при этом не сломав.

Какое-то время спустя, конечно, мне всё равно пришлось ставить на обе машины Python 3.7, но в то время мне было прям очень лень этим заниматься, так что пришлось искать другую библиотеку. Но со стороны AIOGram выглядит таким же достойным проектом, как pyTelegramBotAPI, зато с асинхронным I/O.

Мой выбор в итоге остановился на библиотеке aiotg. Её API значитеьно беднее, из-за чего приходится напрямую работать со словарями и списками, а в некоторых местах даже вручную генерировать JSON для запросов, но зато требуется только Python 3.5, а в плане запуска бота она даже проще, чем pyTelegramBotAPI (см. метод run_webhook; правда пришлось всё равно лезть в исходники и высматривать метод create_webhook_app, чтобы нормально реализовать возможность запуска одного бота как на поллинге (что проще использовать при разработке), так и с использованием веб-хука (для продакшена)).

Итак, запускался бот сначала на домашнем сервере, представляющем собой одноплатный компьютер Orange Pi PC, о котором мы говорили в одной из прошлых статей. Там стоял дефолтный Питон 3.5. Когда начались проблемы с ddclient'ом, бот переехал на VPSку, на которой по сей день крутится Пайтон 3.6. Но поскольку я его компилировал когда-то вручную сам, то там нет половины модулей, включая sqlite3, который требует наличие скомпилированной библиотеки _sqlite3.so. Я попытался скомпилировать версию 3.7 с нужным флагом, но поскольку VPSка дешёвая и слабая, то в результате только уронил веб-сервер, которому стало не хватать ресурсов, а сборка всё равно завершилась с ошибкой. Плюнув, я перенёс бота обратно на недавно оживший домашний сервак, на который к этому времени я уже поставил Python 3.7.

Избавившись от зависимости на старую платформу, я с радостью выделил бота в отдельный репозиторий, причём не на Битбакете, а на Гитхабе, чтобы позволить другим программистам присылать pull request'ы с новыми преобразованиями текста (поскольку GitHub сейчас популярнее и распространённее, чем Bitbucket). Если прошлый репозиторий вёлся в духе вечной разработки: без файла лицензии и README, без тестов, с отсутствующими докстрингами, с гуляющей от модуля к модулю степенью аннотированности типов и так далее — то новый оформлен аккуратно: с автоматическим запуском тестов, с примерами конфигурационных файлов для системного менеджера (чтобы перезапускать бота при случайном падении) и фронт-сервера, который пришёл на смену системе роутинга, позаимствовав из неё некоторые соглашения и вынеся принятие и перенаправление сообщений наружу из интерпретатора в отдельный веб-сервер (в моём случае в nginx). Теперь настройка SSL-сертификата происходит на стороне фронт-сервера, после которого все запросы к боту пробрасываются без шифрования через Unix domain socket.

Также, избавившись от платформы, мне пришлось попрощаться с модулем botutils.multilang. Но поскольку для полноценного релиза бота наличие английской локализации всё-таки необходимо, то было решено вынести его в отдельный пакет и залить на PyPI, о чём я расскажу в следующий раз (опять анонсы =) ).

Теперь расскажу о возникших во время работы над проектом проблемах. Первая из них проявилась буквально через несколько минут после первого тестового запуска бота на реальных пользователях: перевести текст в бинарный код — легко, а вот обратно — невозможно для длинного исходного текста, так как не хватает лимита по максимальной длине inline-запросов, специфицированного в Telegram Bot API как 512 символов. Для решения данной проблемы я решил прикреплять к сообщениям кнопку «Расшифровать», заодно избавив пользователей от необходимости копировать текст трансформированных сообщений и переводить их обратно вручную.

Но и тут кроется закавыка со стороны API! Данные, которые можно прикреплять к кнопке, ограничены всего 64 байтами! Понятно, что ничего дельного туда не уместишь. И вот именно для решения этой проблемы мне пришлось переносить бота обратно на Апельсин, чтобы воспользоваться СУБД SQLite3 для хранения исходного текста всех запросов, которые поступают боту. Для каждого из них генерируется числовой идентификатор, который прикрепляется к кнопке.

Ну и напоследок расскажу о проблеме в самой библиотеке aiotg, с которой я столкнулся, как раз при реализации inline-кнопок, а если точнее, то при реализации функции-обработчика запроса на расшифровку, отправляющегося при нажатии на кнопку. Связана она с тем, что разработчик библиотеки не предусмотел возможность отсутствия у CallbackQuery поля message при отправке запроса от кнопки, прикреплённой к сообщению, отправленному через inline-бота. Вот такая вот тонкая особенность API. Подробнее проблему я описал по-английски в соответствующем issue на GitHub, так что не вижу смысла повторяться. Пока же ошибка не исправлена, пришлось добавить временное решение проблемы.

На всякий случай ещё раз напоминаю, что информацию обо всех новых изменениях в работе ботов я буду писать в своём Telegram-канале. Данный пост больше обновляться и дописываться не будет!


Добавлено 8.11.2018

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

Пока я делал функцию создания Б А Н Н Е Р О В   С   Т А К И М И сообщениями, решил отрефакторить ту гору костылей, которая занималась выборкой нужного обработчика для текста. В этот запутанный ад из условий трудно было добавлять новую логику, так что я решил всё переделать. В результате появилась полноценная система динамической загрузки обработчиков при старте приложения. Все обработчики теперь представляют собой классы, унаследованные от базового абстрактного класса TextProcessor и находящиеся в различных модулях внутри пакета strconv. Сам интерфейс класса TextProcessor и процесс создания новых «фич» подробно расписан в специально написанном мною официальном гайде на Гитхабе (надеюсь, ни один программист в 2018 году уже не боится английского языка? =) ).

В целом рефакторинг занял приличное количество времени (саму фичу я добавил за пять минут, но на рефакторинг, продумывание всех деталей и написание документации ушло три вечера), но я доволен получившимся результатом. Теперь добавлять новый функционал стало действительно легко, просто и удобно. Вот что значит соблюдать принципы SOLID — в частности open-closed principle! Так что самое время контрибьютить в open source, дорогие друзья!

Ну и упомяну уж про вторую функцию тоже. Второй обработчик, вошедший в релиз 1.1.0, выполняет следующие преобразования:

Исходный текстРезультат
""«»
(c)©
(r)®
(tm)

Добавлено 17.11.2018

Очередное обновление, и снова достаточно значимое, чтобы дополнить пост на сайте.

Сегодня вышли сразу две версии бота: 1.1.1 и 2.0.0. Первая спокойно ставится поверх v1.1.0 и добавляет следующие преобразования в модуль «Элитный типограф»:

Исходный текстРезультат
!= и ~= и
>= и <= и
...

Также я немного поменял преобразование двойных кавычек, сделав его по аналогии с ВКшным и KozMULовским:

Старый синтаксисНовый синтаксисРезультат
"foo" <<foo>> «foo»

Ну и поскольку начались подвижки по проблеме с aiotg, то я откатил свои изменения. Правда, до полноценного релиза следующей версии библиотеки, похоже, ещё далеко, так что пришлось прописать зависимость прямо на коммит (оказывается, pip так умеет!) Хотя с последними изменениями вообще пропала основная причина использовать эту библиотеку. Впрочем, в эпоху Докера уже нет никаких оправданий использовать старые версии Питона и подходящие к ним пакеты.

И вот мы плавно подошли к главному нововведению второй мажорной версии бота: к контейнеризации. Меня наконец-то убедили разобраться с конфигурацией и попробовать Docker. Знакомство прошло не очень гладко. Сначала я безуспешно пытался настроить проксирование контейнеров через SOCKS5-прокси, но, видимо, советы по указанию переменных среды HTTP_PROXY и HTTPS_PROXY всё-таки про другое (или я что-то делал не так). Провозившись вечер, я плюнул и арендовал себе новый сервер в Амстердаме у компании Scaleway с одним гигабайтом ОЗУ за €2 в месяц. Так что теперь бот должен работать гораздо быстрее, лишившись всех посредников в лице прокси-серверов.

Избавившись от проблемы с заблокированностью «эндпоинтов» Telegram API, я продолжил свои мучительные эксперименты. В итоге всё-таки ещё за два вечера более-менее разобрался с Докером и Docker Compose, написав и оттестировав конфигурационные файлы. В результате, правда, пришлось вынести файл базы данных и модуль с конфигурацией в папку app/data. Эти файлы должны быть вне контейнера, так что данная папка монтируется в него при запуске как есть. Именно это изменение привело к нарушению обратной совместимости и потребовало увеличение мажорной версии. Ну и то что в скриптах init.sh и start.sh теперь виртуальное окружение разворачивается в папку venv, а не прямо в корень репозитория. Да, старый способ запуска никуда не делся и всё так же доступен для использования.

Ну ещё я добавил защиту от спамеров, заваливающих мне личку через функцию отправки сообщений, но это обновление едва ли заслуживает обсуждения.