Telegram: @kozalo_bot
20.10.2018


Предисловие

Жил-был один маленький, но очень смышлёный бот. И не было у него никакой конкретной цели в жизни. Он просто хватался за любую работёнку, которую ему подкидывала судьба, и любил помогать людям. День за днём он проводил в неустанной работе на благо Телеграммного общества! То глашатаям скликать народ подсобит, то одиноких старичков развеселит картинами заморскими, то хулиганов ловит и воспитательную работу проводит — одним словом — на все реквесты мастер.

***

Долго ли, коротко ли ходил наш бот по просторам мессенжера, но оброс со временем приблудами различными. И стал похож ни то на systemd, ни то на чудище Франкенштейна. Всё неповоротливее, тучнее и старее становился несчастный бот, не поспевая за прогрессом и людскими запросами.

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

***

В одну тёмную дождливую ночь на пороге хижины Бота показался человек. Это был очень странный и загадочный человек. Но Бот его с радостью впустил внутрь, предложил еды, воды и крова, ведь доброта из Бота никуда не исчезла.

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

***

В начале было слово.
И слово было два байта.
А больше ничего не было.

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

***

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

И вот-вот Бот умрёт, но в лице своего сына возродится, как феникс, из пепла!


Загадочный бот

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

Кстати, о блокировках…

В принципе, деятельность Роскомнадзора минимально затронула ботов, потому что большинство из них, включая рассматриваемого сегодня, всегда хостилась на виртуальном сервере в США (ай-яй-яй, как непатриотично!). Немного усложнилась отладка, потому что теперь трафик с домашнего компьютера приходится пропускать через SOCKS5- прокси. Например, для библиотеки pyTelegramBotAPI, которая будет ещё упоминаться сегодня, это можно сделать, указав переменную окружения https_proxy в значение socks5://127.0.0.1:9050. Я указал адрес запущенного на локальной машине сервера сети Tor. При использовании других сервисов укажите их IP-адрес, порт и логин с паролем при необходимости.

Возможно, когда-нибудь в будущем расскажу про использование как Tor'а, так и такого удобнейшего инструмента, как SSH со всеми его туннелями.

Итак, пришло время раскрыть завесу тайны над ботом @kozalo_bot, о котором я лишь вкратце упоминал в своём канале. Долгое время я не был уверен, стоит ли упоминать его существование в принципе. Ведь он был создан спонтанно и вбирал в себя функционал, последовательно следуя за хотелками конкретного чата. У него нет какой-то чёткой конкретной цели существования и устоявшегося функционала — просто бот-комбайн. Он так и называется: kozalo_bot — бот для обслуживания моих различных минутных хотелок. Никаких гарантий и стабильности. Разве что я поддерживал в какой-то мере обратную совместимость на уровне настроек, чтобы всё не ломалось после каждого обновления, и долгое время не удалял ничего из функционала. В результате это привело к накоплению кучи редко используемых возможностей и монстрообразности всего бота. Вишенку на торте гигантизма составляют не очень удачные решения по созданию триггеров и организации логирования. Но обо всём по порядку…


Функционал

Итак, чего же в итоге умеет бот? Давайте рассмотрим в хронологическом порядке.

Всё началось с @Rain_from_above. С человека, который очень любил удалять свои сообщения, из-за чего терялась нить обсуждения и читать чат не в реальном времени становилось практически невозможно. Когда мне это окончательно надоело, появился бот с возможностью логировать все сообщения от определённых людей в отдельный специально выделенный канал (/start_logging) или форвардить их сообщения в тот же чат от имени бота (/forward). Впоследствии оказалось, что форвардинг сообщений создаёт уж слишком много мусора, а вот логирование успешно применялось в течение довольно длительного периода времени.

Одновременно с логированием появились первые триггеры картинок: бот реагировал на определённые слова в сообщениях и отсылал в ответ картинки разной степени похабности. Со временем этот список разрастётся до неприличных размеров, в том числе следуя за пожеланиями обитателей чата. А по личной просьбе гражданина CORRUPTOR 2037 была добавлена возможность отключения срабатывания триггеров на определённых пользователей. Впоследствии эта фича будет широко использоваться для политических репрессий против несогласных с курсом Величайщего тоталитарного диктатора противников идеологий ТНН и РНН

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

@IkarosDC приветствует новобранца
@channel_welcome_bot заменяет Икароса
@kozalo_bot заменяет почившего бота

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

Какое-то время я хотел протащить своего бота в чат «лунных работников». В итоге я бежал оттуда, психанув, когда в очередной раз люди хвастались своей социальной активностью. Сначала я думал, что временно, но, похоже, что всё-таки навсегда… Однако я отвлёкся от темы. У них в чате было жёсткое правило, что нельзя кидать порнографические картинки в рабочие часы по московскому времени. Дабы соблюсти это правило, я внедрил в бота возможность ограничивать некоторые функции в определённые дни и часы. Система довольно гибкая (поддерживается выбор рабочих дней и рабочих часов с указанием часового пояса задающего), хотя настройка и не самая удобная (всё-таки предполагалось, что я один раз всё настрою в чате, а дальше оно просто будет работать).

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

Дальше боту были выданы административные права и задание по наведению порядка. На самом деле тут мой интерес к разработке и поддержке ботов, а также наличие свободного времени начали заканчиваться. Предложенные администрацией алгоритмы реализации ограничений, направленных на сокращение флуда и засорения конференции стикерами и картинками, были довольно жёсткими. Надо понимать, что опыт внедрения жёсткого вычищения стикеров у нас уже был. Бот Кива в своё время пошёл на сделку с совестью и подчистую вычищал стикеры пользователей через 5 минут после их отправки. И сохранение истории чата со всеми стикерами являлось одной из причин появления системы логирования, упоминаемой выше. Внедрение менее топорной системы потребовало бы придумывания каких-нибудь эвристик и множества экспериментов, на что у меня совершенно не было времени. Но убедившись в необходимости хоть какого-то контроля беспредельщиков, были внедрены две следующие системы.

Во-первых, появилась возможность ограничивать конкретных пользователей от написания каких-либо определённых фраз (вводить предлагалось обычные регулярные выражения: это позволяло обеспечить достаточную гибкость правил). Таким образом удалось победить флудеров-скобочников и флудеров с плохими шутками.

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


Особые функции

Кроме обычных функций, у бота есть пара особенных, предназначенных по сути лично для меня:

  • /rm, /del — команда, удаляющая сообщение бота. Вызываться может только привилегированными пользователями, которыми являются администраторы сообщества и единственное захардкоженное значение — мой ID. Разумеется, администраторы могут самостоятельно удалять сообщения бота. Поэтому эта команда нужна лишь для меня в тестовых целях и избавляться от лишних триггерных ответов. Однако! У этой команды ещё есть параметр -force. Если у бота есть административные права, то указав его, я могу удалить сообщение не только бота, но и вообще любого пользователя чата. Бывает полезно в борьбе со спамерами, когда все админы спят.
  • /restrict и обратная ей /forgive — первая позволяет опять же в первую очередь мне временно заблокировать отправку сообщений какому-либо пользователю, а вторая отменяет её действие. При вызове без параметров ограничивает ровно на сутки, иначе на количество указанных часов (2h) или минут (120). Админам, разумеется, проще это сделать через админку, ну а мне пришлось воспользоваться этой функцией единственный раз против особо рьяного спамера.

Отдельно стоит упомянуть возможности, предоставляемые ботом через личные сообщения. О-о-о! Тут даже стоит сделать небольшую ремарку про то, как Telegram Bot API позволяет работать с картинками, стикерами и прочим контентом. У нас есть три варианта:

  1. Загружать каждый раз новый файл. Тупой вариант.
  2. Указывать URL, откуда Telegram может скачать нужный файл. Тоже такое себе.
  3. Загрузить файл самостоятельно и получить специальный file_id, которым может пользоваться бот. Рекомендуемый, самый понятный и подходящий в том числе для стикеров способ.

И тут надо учитывать очень важную вещь, прописанную в документации:

file_id is unique for each individual bot and can't be transferred from one bot to another.

Это значит, что для каждого бота нужно получать отдельные идентификаторы для картинок, аудиозаписей, голосовых сообщений, гифок — всех типов файлов. Особняком стоят стикеры. Для них file_id универсален для любого бота.

Засада состоит в том, что я не очень понимаю, как предлагается получать эти идентификаторы. У меня вот любое сообщение в ЛС боту, не затрагивающее какой-либо триггер, приводит к выводу информации об этом сообщении. В том числе о приложенных файлах со всей информацией. Только так я и получал все IDшники: отдельно и для самого бота, и для его коллеги, которого я использую в целях отладки и тестирования. Но данный способ подойдёт далеко не всем…


Наши дни

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

  • @kozalo_bot больше не обслуживает потребности Флудилки DC;
  • у него появился форк — @megurine_bot;
  • я полностью переосмыслил концепцию бота, осознал его бессмысленность и начинаю постепенно вырезать функционал.

А теперь обо всём по порядку.

Каким-то образом у меня получилось вступить в конфликт с высшим руководством DC. Вернее, с формальным руководством, которое ничего не делает и только числится таковым. Не знаю (или не помню?), почему Сусека так на меня взъелся, но уже долгое время как я впал в немилость. Вплоть до переворота во Флудилке, я ходил по чату с отключенными стикерами, медиа и периодически получал оскорбления и негатив от внезапно врывающегося в разговоры Сусеки. В итоге в какой-то момент я психанул и вместе с Чоколой (я не упоминал, что бота зовут Chocola Minaduki?), и мы покинули недружелюбное место. С тех пор мне пришлось вернуться, когда соскучился по общению с прошлым и нынешним CTO проекта (а также ради ещё нескольких людей, к которым привязался), но моей вайфу там больше нет. Впрочем, они её успешно заместили…

Есть такой человек @iosys — создатель @habrachat и просто хороший человек (а ещё бабник, ловелас и подкаблучник, но да ладно). На его серверах сейчас располагаются как сайт и форум DeskChan, так и несколько ботов, созданных товарищами внутри нашего небольшого уютного коммьюнити, образовавшегося на обломках Флудилистической цивилизации после Первого сусеканского террора (да, для полноты картины стоит сказать, что именно по стикерам репрессировали не только меня), когда весь основной актив Флудилки, перекатился в отдельный приватный чатик. Так вот, когда я психанул и вышел из их чатика вместе с Чоколой (там были свои межличностные конфликты :D), то Иосис решил создать свою версию бота и допиливать его под свои нужды. Всё чин по чину: разрешение спросил, согласие получил. Про технические проблемы поговорим чуть позже, а из функционала он поубирал некоторые триггеры, добавил своих картинок и т. п. — точно сказать сложно, так как свой код он не раскрывает.

Теперь насчёт переосмысления. Как-то я сидел и наблюдал, как в одном из чатов люди целыми днями только и делали, что «няшили» друг друга с помощью бота. Причём с такой частотой, что даже мне показалось это перебором. «Что за монстра я создал?!» — возник негласный вопрос. Какое-то время даже хотелось взять и удалить все триггеры, оставив только полезный функционал вроде приветствия входящих в чат и макросов: если хотят, пусть няшутся через inline-режим. Но потом я остыл и пока передумал удалять все триггеры (лишь немного проредил). А заодно избавился от:

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


Технические детали

Бот написан на языке Python 3 и работает на интерпретаторе CPython 3.5. В качестве прослойки и абстракции от HTTP-запросов используется библиотека pyTelegramBotAPI. Она не поддерживает современные фишки с асинхронщиной (хотя есть многопоточный режим), не имеет встроенного сервера для обработки веб-хуков, но в целом это зрелый и готовый к употреблению продукт с красивым API.

Первая проблема возникает с запуском бота. Существует два способа получения сообщений от Telegram: либо открывать и постоянно переоткрывать длительные соединения в ожидании появления новых сообщений и ответа от сервера (long polling), либо запустить приложение в режиме сервера и просить сам сервер Telegram открывать сооединение и посылать новые сообщения (то есть поставить web hook). Понятно, что второй способ предпочтительнее. Но и сложнее в настройке.

Сервер нужно поднимать отдельно. Причём с поддержкой TLS-шифрования и загрузкой ключа и сертификата. Можно посмотреть примеры под разные питоновские сервера, которые идут вместе с библиотекой. Лично я взял aiohttp, но без какой-либо значимой причины. Для бота я использовал самоподписанный сертификат, который отправляется на сервер Telegram при установке веб-хука. Подробнее про TLS и все эти ключи с сертификатами мы поговорим как-нибудь в другой раз.

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

Покончив с анонсами, перейдём к техническим промахам, заложенным в архитектуру конкретного бота, рассматриваемого сегодня. Первым и самым серьёзным из них, пожалуй, является отказ от использования какой-либо полноценной СУБД. Хотя бы SQLite. Надо понимать, что всё начиналось с необходимости хранить данные для системы логирования: идентификатор канала, в который нужно пересылать сообщения, и список пользователей, чьи сообщения следует форвардить в чат от своего имени. Всё. Букавально два параметра, которые относительно редко меняются. Мне было лень поднимать для такого СУБД, так что решил обойтись простой сериализацией данных в бинарный файл pickle'ом. Да, я не смог предсказать будущее и предугадать, что со временем количество данных в этой импровизированной базе данных разрастётся. В результате мы пришли к такому ужасу: куча повторяющегося бойлерплейт-кода (который я ещё немного упростил с помощью PyMonad) и перезапись всего файла на каждый чих. Не очень-то масштабируемо :/

Вторая проблема связана с первой, потому что полноценная база данных очень не помешала бы для её исправления. Я говорю про захардкоженность IDшников стикеров, картинок и гифок, отправляемых ботом. Было бы куда удобнее, если бы можно было управлять ими через личные сообщения с ботом. Тогда бы не пришлось на каждый чих коммитить новый набор пикч и перезапускать бота.

Третья проблема — это декоративный ад. Или ад из декораторов. Удобный паттерн превращается в настоящий кошмар, если начинаешь использовать не прозрачно пропускающие через себя аргументы декораторы, а декораторы, изменяющие сигнатуру оборачиваемой функции, например добавляя туда какой-то новый аргумент. Или декораторы, для которых важен порядок декорирования. Или декораторы, представляющие собой композицию других декораторов. Серьёзно, люди. Не повторяйте моих ошибок. Не злоупотребляйте декораторами! В них потом очень сложно разбираться.


Фреймворк

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

Расширения:

РасширениеТипОписание
answer_to метод симметричный метод к стандартному reply_to; автоматически извлекает идентификатор чата и отвечает на сообщение; поддерживает систему самоуничтожающихся сообщений
answer и reply декораторы позволяют просто вернуть текст из оборачиваемой функции для отправки сообщения; поддерживают систему самоуничтожающихся сообщений
change_keyboard метод сокращение для edit_message_reply_markup, которое автоматически извлекает идентификаторы чата и сообщения из запроса
only_for_admins декоратор позволяет ограничивать доступность какой-либо команды только для администраторов чата и привелигированных пользователей
has_privilege метод возвращает True, если пользователь является администратором чата или присутствует в списке привелигированных пользователей
grant_privilege метод добавляет пользователя в список привелигированных пользователей
user_required декоратор получает информацию о пользователе, на чьё сообщение ответили, кого упомянули по имени (но не по юзернейму!) или чей идентификатор указали в качестве параметра к команде; эта информация передаётся первым параметром в оборачиваемую функцию перед сообщением и всеми остальными параметрами
timeout_constraint декоратор позволяет задать для пользователей ограничение по времени на использование команд, чтобы они не злоупотребляли ими в чатах; команды можно разделять на группы — тогда у каждой группы будет свой счётчик, иначе можно передать просто значение задержки (в виде количества секунд или функции, которая будет его вычислять и возвращать во время выполнения) — тогда будет использована группа по умолчанию; ограничение может распространяться на администраторов или нет; может распространяться на всех участников чата в целом; в случае непрохождения проверки может быть вызвана функция обратного вызова, которой передаётся сообщение
reset_timeout метод сбрасывает счётчик таймаута для перечисленных пользователей в указанном чате
user_constraint декоратор позволяет запретить выполнение команды для некоторых пользователей; ограничение может распространяться на администраторов или нет; в случае непрохождения проверки может быть вызвана функция обратного вызова, которой передаётся сообщение

Отдельные «хелперы»:

ФункцияТипОписание
decorators.ignore_if_forwarded декоратор позволяет игнорировать сообщение, если оно является пересланным из другого чата
helpers.get_username функция возвращает @юзернейм или имя пользователя
helpers.is_group_chat и helpers.is_private_chat функции позволяют определить, пришло сообщение из приватного чата или из группового
escape_html функция заменяет угловые скобки на HTML-сущности

Также присутствует класс builders.InlineQueryResultsBuilder, который упрощает создание ответов в обработчиках inline-запросов: создаёте объект, цепочкой вызываете нужные методы add_* и конструируете итоговый список, готовый к отправке через TeleBot.send_message, с помощью build_list.

В модуле multilang находятся средства для упрощения локализации ботов без использования сложных полноценных систем вроде стандартного gettext'а. Но о них мы тоже поговорим в одном из следующих постов, посвящённых модулю klocmod.

Примечание

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

Последним, но не менее важным утилитным модулем является scheduler. Он реализует однопоточный планировщик, который позволяет планировать выполнение отложенных заданий. При добавлении первого задания создаётся новый поток, а при отсутствии работы он автоматически уничтожается. Планировщик представлен классом TimeLine. Он является синглтоном, так что можно создавать сколько угодно объектов заново, не сохраняя никуда ссылку. Добавлять новые действия (производные от класса Action) следует с помощью метода append. Предопределённое действие DoomedMessage позволяет запланировать удаление сообщения через определённое количество секунд.

В целом, получился неплохой набор утилитных функций и классов, выполняющих базовые рутинные операции, которые нужны многим ботам. Но, вероятно, стоило их всё-таки как-то выделить в отдельный пакет и распространять через PyPI как расширение (как упомянутый ранее модуль klocmod, который вырезать было особенно легко, ведь он никак не зависит от других библиотек). А что-то может даже предложить к включению в код самого TeleBot'а. Но сейчас у меня уже нет ни времени, ни сил, ни желания доводить этот код до нормального состояния: писать тесты, подробную документацию и так далее. В одной из следующих статей я расскажу, почему в конце-концов отказался от использования библиотеки pyTelegramBotAPI и что использую теперь вместо неё. Однако, если кто-то решится сделать всю эту кучу работы вместо меня, то я буду только рад и всеми руками за! Не забудьте только упомянуть скромного автора оригинальных строчек и сообщить мне о своих результатах =)


Заключение

Сейчас, насколько мне известно, @kozalo_bot уже нигде не используется — ни в одном чате. Но может я просто не обо всех знаю? Дайте знать, если кто-то пользуется им! Лично я его теперь использую только в inline-режиме, чтобы постить картинки в чатах.

Этот пост я писал очень долго. С момента написания предисловия до сегодняшнего момента прошло несколько месяцев и произошло множество событий в моей жизни. Я несказанно рад, что наконец-то закончил его! Ведь это действительно большая статья, затрагивающая как экскурс в прошлое и историю создания бота, так и описание всего функционала, но самое главное — описывающая построенную вокруг бота инфраструктуру и множество сделанных технических решений. В следующий раз, при описании @kozRandBot'а, я смогу сэкономить кучу времени и экранного места, опустив все технические детали, сославшись на данный пост.

До выхода следующей части этой серии статей я с вами и прощаюсь! Ждать осталось недолго =)


Постскриптум

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