Привет, Хабр! Сегодня я хочу рассказать о том, как писал Telegram-бота, да не простого, а подарочного. Прошу под кат тех, кому эта история кажется забавной, а также тех, кто пытается писать своих Telegram-ботов на Java. Возможно, мой небольшой опыт будет в чем-то полезен.
Я работаю программистом чуть более шести лет, пишу в основном на Java и 1С, звезд с неба не хватаю, но поставленные задачи выполняю.
Весной 2017 года меня заинтересовало создание ботов для различных мессенджеров. Сначала хорошей идеей казалось создание бота в Viber. В Сибири он наиболее популярен, в нем сидят практически все знакомые, корпоративные чаты ведутся тоже в нем. Кроме того, вдохновляла эта статья. Однако, создание публичного аккаунта оказалось не такой простой задачей – на все запросы приходили немногословные отказы.
Помучившись с Вайбером около недели, я обратил внимание на Телеграм, оказалось, что зарегистрировать бота там очень легко, а жизнь джава-программиста облегчается наличием библиотеки TelegramBots. В качестве пробы пера был написан бот, позволяющий получать некоторые отчеты из корпоративных систем, потом был бот со справочной информацией, а осенью уже для себя я попробовал сделать бота для предварительной записи в салон красоты. Все это были интересные поделки, которые нравились окружающим, а первые два бота даже реально использовались в работе. Благодаря этому, в голове постоянно крутились мысли о том, как еще можно применить ботов в быту и народном хозяйстве.
На выполнение всех работ оставалось всего 4 дня, а если говорить точнее, то 4 вечера, да и то неполных. Под рукой имелись исходники трех других ботов, которые можно использовать как «запчасти», и было понятно, что впереди ждет увлекательное ралли на велосипеде из костылей.
В качестве языка программирования была выбрана Java, в качестве библиотеки, для работы с API Telegram – TelegramBots, для хранения базы вопросов использована СУБД H2.
Первой задачей являлось создание базы вопросов. Для этого пришлось провести большую работу по сбору фотографий с телефона, рабочего и домашнего компьютеров и соцсетей. Полученные фотографии были структурированы таким образом, что получилось 26 вопросов, к каждому из которых прилагалось от 2 до 4 фото и 4 варианта ответа. При этом заведомо правильных вариантов ответа не предусматривалось, а ответ на каждый вопрос просто сопровождался комментарием. Хотелось также сделать сохранение истории выбранных вариантов ответа, но в самый последний момент я просто забыл прикрутить эту фичу.
Раскладка фотографий и придумывание вопросов оказались очень трудоемким процессом, и на них ушло полтора вечера.
Далее была реализована база данных, хранящая вопросы. Ниже приведены описание таблиц базы данных и DDL-скрипт.
После создания база была наполнена данными вручную, благо в Netbeans, который я использую как среду разработки, достаточно удобный редактор SQL-скриптов.
По истечении двух дней база вопросов и фотографий была готова, времени оставалось совсем немного, пора было переходить к созданию самого бота.
Напомню, что для создания бота в Telegram необходимо написать @BotFather, пользуясь командой /newbot ввести для бота отображаемое имя и имя пользователя. После выполнения этих действий будет получен токен для доступа к API Telegram. Выглядит это примерно так.

Для красоты добавим фото профиля с помощью /setuserpic.

Теперь перейдем к созданию самого бота с помощью TelegramBots. Напомню, что Telegram позволяет создавать боты работающие с Webhooks и LongPolling-боты. Выбран был второй вариант. Для создания LongPolling-бота, необходимо реализовать собственный класс, наследующий классу
Метод
Бот проверяет, является ли входящее обновление текстовым сообщением
Создаваемый бот является обычным консольным приложением и его запуск выглядит следующим образом:
Ничего сложного в инициализации бота нет, однако хочу обратить внимание, что достаточно важно предусмотреть возможность указать боту прокси. В нашем случае настройки прокси хранятся в обычном properties-файле, откуда считываются при старте программы. Также замечу, что в приложении используется собственный нехороший велосипед в виде некоего подобия глобального контекста
Работа бота естественно начинается с обработки команды /start. Как было написано выше, эта команда обрабатывается методом
В начале метода объявим смайлы, которые будут использоваться в тексте приветственного сообщения.
Далее производится проверка введенной команды и если это команда /start, то формируется ответное сообщение
Интересным моментом является установка данных колбэка. Эти данные могут использоваться при обработке нажатия кнопок. В нашем случае в данные колбэка записывается сериализованный в JSON объект. Этот способ тяжеловесен для данной задачи, но позволяет работать с данными возврата без лишних заморочек на преобразование. Данные возврата формируются в специальном билдере
Для того, чтобы
Маршаллер, который используется в
И, наконец, производится отправка сообщения:
В итоге, приветственное сообщение выглядит вот так.

Оставалось сделать самое интересное — реализовать логику работы бота.
Для работы с базой вопросов использовалось JPA. Приведу код классов-сущностей.
Также замечу, что здесь и далее для доступа к данным используется объект, реализующий интерфейс
Теперь перейдем к тому моменту, когда нажимается кнопка «Начать!». В этот самый момент бот обрабатывает очередную порцию входящей информации и вызывает ранее упоминавшийся метод
Если викторина только начата, то необходимо проинициализировать список вопросов и задать первый вопрос.
Сейчас рассмотрим инициализацию списка вопросов
В методе
После инициализации задается первый вопрос. Но об этом поговорим чуть позже. А пока рассмотрим ситуацию, когда пришел ответ на уже заданный вопрос и боту необходимо отправить комментарий, относящийся к этому варианту ответа. Здесь все достаточно просто, сначала ищем ответ в базе данных (уникальный идентификатор варианта ответа сохранен в
Потом готовим на основе найденного ответа сообщение и отправляем его:
Теперь рассмотрим метод
Если Enumeration еще содержит элементы, то готовим вопрос к отправке, в противном случае пора выводить сообщение об окончании викторины. Отправляем сам вопрос:
Теперь отправляем фотографии, относящиеся к данному вопросу:
И, наконец, варианты ответа:
Клавиатура с вариантами ответа формируется следующим образом
Вопрос отправлен. Выглядеть это будет примерно так (фотографии немного размыты, чтобы никого не смущать).

В тот момент, когда вопросы закончились, будет сформировано сообщение об окончании викторины:
И в самом конце отправим забавный стикер:

На этом работа бота завершается.
Работа над всей описанной функциональностью была завершена где-то в 11 часов вечера дня, предшествующего дню X. Замечу, что имея в виду некоторые особенности, я понимал, что запустить бота надо ровно в 12 ночи. В связи с этим я испытывал некоторый цейтнот (почему и забыл про историю ответов). Кроме того коллегу необходимо было как-то оповестить об этой викторине. По ряду причин просто скинуть ссылку я не мог, поэтому доверил оповещение другому боту (благо в процессе тестирования идентификатор пользователя был сохранен и бот мог свободно писать). На этом история написания бота заканчивается.
Каких-то глубоких выводов из этой истории не будет. Сам бот был принят с интересом и понравился, так что старался я не зря. Если переходить к технической части вопроса, то работа над ботом позволила задуматься над тем, что от некоторых костылей и велосипедов пора избавляться, что я и попытался сделать в следующем боте.
Также хочу заметить, что сама идея ботов очень перспективна. Есть множество повседневных задач от заказа пиццы до вызова такси, которые предлагается решать посредством мобильных приложений, сайтов и их мобильных версий или телефонных звонков оператору. С одной стороны все эти способы доказали свою эффективность, удобны и в ближайшее время вряд ли изменятся. С другой стороны приложения для мобильных устройств хотя и богаты функциональностью, но требуют установки, обновления и изучения их интерфейса, а еще имеют свойство кушать батарею. Сайты и их мобильные версии требуют от пользователя как минимум перехода в браузер и работы с новым интерфейсом, что не всегда удобно, особенно на мобильных устройствах. Взаимодействие по телефону удобно для многих, но не подразумевает какой-либо визуализации в принципе, а кроме того оператор всегда будет узким местом системы. При решении тех же задач боты не требуют установки чего-либо кроме мессендежера, не требуют изучения новых интерфейсов и позволяют пользователю работать асинхронно (в отличие от телефонного звонка) в относительно знакомом интерфейсе мессенджера. Мессенджер в таком случае предоставляет некую среду для клиент-серверного взаимодействия, где в роли сервера выступает бот, а клиентская часть реализована средствами мессенджера. Безусловно, при работе с ботами для пользователя также возникают различные трудности, в чем-то схожие с трудностями работы с текстовыми интерфейсами, в чем-то обусловленные ограничениями самих мессенджеров. Но все равно боты, ориентированные на решение небольших повседневных задач (или на развлечения, как бот, описанный в этой статье) представляются перспективными.
Этой мой первый пост, буду признателен за конструктивную обратную связь!
UPD: Залил проект на GitHub github.com/altmf/questbot
Краткая предыстория
Я работаю программистом чуть более шести лет, пишу в основном на Java и 1С, звезд с неба не хватаю, но поставленные задачи выполняю.
Весной 2017 года меня заинтересовало создание ботов для различных мессенджеров. Сначала хорошей идеей казалось создание бота в Viber. В Сибири он наиболее популярен, в нем сидят практически все знакомые, корпоративные чаты ведутся тоже в нем. Кроме того, вдохновляла эта статья. Однако, создание публичного аккаунта оказалось не такой простой задачей – на все запросы приходили немногословные отказы.
Помучившись с Вайбером около недели, я обратил внимание на Телеграм, оказалось, что зарегистрировать бота там очень легко, а жизнь джава-программиста облегчается наличием библиотеки TelegramBots. В качестве пробы пера был написан бот, позволяющий получать некоторые отчеты из корпоративных систем, потом был бот со справочной информацией, а осенью уже для себя я попробовал сделать бота для предварительной записи в салон красоты. Все это были интересные поделки, которые нравились окружающим, а первые два бота даже реально использовались в работе. Благодаря этому, в голове постоянно крутились мысли о том, как еще можно применить ботов в быту и народном хозяйстве.
Теперь необходимо сделать лирическое отступление, чтобы рассказать о том, для кого же делался подарок (те, кому интересны только технические подробности, могут смело пропускать).
По работе в качестве программиста на 1С мне много приходится общаться и совместно работать с очень симпатичной коллегой, коллега кроме того, что симпатичная, еще и умна, а временами (когда настроение хорошее) достаточно глубоко вникает в вопросы разработки (а при желании может и запрограммировать что-нибудь) и вообще обладает системным мышлением. А системное мышление – венец женской привлекательности, кто бы спорил. Так вот, со столь привлекательной коллегой мы часто обсуждаем всяческие свистоперделки для 1С (обсуждаем на мою голову, так как потом они попадают в очередные требования к разрабатываемой системе), разные новые технологии и околоайтишные темы. Кстати говоря, своего первого бота, я показывал именно этой коллеге и получил тогда неплохую обратную связь и импульс к изучению темы.
Здесь необходимо заметить, что у коллеги в самом начале декабря намечался день рождения, ну и естественно не поздравить её было нельзя. Достаточно очевидно, что коллеги с системным мышлением в качестве подарков могут рассчитывать не только на конфеты, цветы и прочие ништяки, но и на что-нибудь, претендующее на остроумность. Так в 2016 году в качестве подарка была закастомлена главная страница той системы, которую мы вместе делаем, и в день рождения вместо стандартной заставки коллегу встречало поздравление. Вроде бы мелочь, но в нашей организации никого так не поздравляли и подарок, как говорится, зашел, да так зашел, что коллега посчитала это поздравление самым лучшим в тот день (надеюсь, так и было). После таких достижений было понятно, что в 2017 году надо развить тему и снова дополнить стандартный поздравительный набор чем-нибудь айтишным. В голове смутно бродили мысли о том, что «что-нибудь айтишное» могло бы быть связано с ботами, но четкой идеи не было, и я почти смирился с тем, что ничего оригинального придумать не получится. Время шло, до дня рождения оставалось 5 дней…
Я возвращался с работы домой, пробка текла вяло, и можно было углубиться в свои мысли: планы разработок, технические решения, автоматизация с помощью ботов, предвкушение пятничного пива, в общем, стандартная каша. Вдруг в голове промелькнула мысль, что коллеге все же надо подарить бота, пускай совсем простенького, например, выдающего разные памятные для коллеги фоточки. Мысль показалась совсем несвежей, однако я начал прикидывать, какие фото можно будет использовать. При этом фото было много, а вот обстоятельства, при которых они делались, вспоминались уже с трудом. Тут-то и появилась идея подарочного бота: шуточная викторина, в которой каждый вопрос сопровождался бы фотографиями коллеги и несколькими вариантами ответов, при этом на каждый ответ бот выдавал бы какой-то смешной комментарий. Эту идею я счел с одной стороны достаточно оригинальной, а с другой – осуществимой за оставшееся до дня рождения время.
Здесь необходимо заметить, что у коллеги в самом начале декабря намечался день рождения, ну и естественно не поздравить её было нельзя. Достаточно очевидно, что коллеги с системным мышлением в качестве подарков могут рассчитывать не только на конфеты, цветы и прочие ништяки, но и на что-нибудь, претендующее на остроумность. Так в 2016 году в качестве подарка была закастомлена главная страница той системы, которую мы вместе делаем, и в день рождения вместо стандартной заставки коллегу встречало поздравление. Вроде бы мелочь, но в нашей организации никого так не поздравляли и подарок, как говорится, зашел, да так зашел, что коллега посчитала это поздравление самым лучшим в тот день (надеюсь, так и было). После таких достижений было понятно, что в 2017 году надо развить тему и снова дополнить стандартный поздравительный набор чем-нибудь айтишным. В голове смутно бродили мысли о том, что «что-нибудь айтишное» могло бы быть связано с ботами, но четкой идеи не было, и я почти смирился с тем, что ничего оригинального придумать не получится. Время шло, до дня рождения оставалось 5 дней…
Я возвращался с работы домой, пробка текла вяло, и можно было углубиться в свои мысли: планы разработок, технические решения, автоматизация с помощью ботов, предвкушение пятничного пива, в общем, стандартная каша. Вдруг в голове промелькнула мысль, что коллеге все же надо подарить бота, пускай совсем простенького, например, выдающего разные памятные для коллеги фоточки. Мысль показалась совсем несвежей, однако я начал прикидывать, какие фото можно будет использовать. При этом фото было много, а вот обстоятельства, при которых они делались, вспоминались уже с трудом. Тут-то и появилась идея подарочного бота: шуточная викторина, в которой каждый вопрос сопровождался бы фотографиями коллеги и несколькими вариантами ответов, при этом на каждый ответ бот выдавал бы какой-то смешной комментарий. Эту идею я счел с одной стороны достаточно оригинальной, а с другой – осуществимой за оставшееся до дня рождения время.
Подготовка и создание базы вопросов
На выполнение всех работ оставалось всего 4 дня, а если говорить точнее, то 4 вечера, да и то неполных. Под рукой имелись исходники трех других ботов, которые можно использовать как «запчасти», и было понятно, что впереди ждет увлекательное ралли на велосипеде из костылей.
В качестве языка программирования была выбрана Java, в качестве библиотеки, для работы с API Telegram – TelegramBots, для хранения базы вопросов использована СУБД H2.
Первой задачей являлось создание базы вопросов. Для этого пришлось провести большую работу по сбору фотографий с телефона, рабочего и домашнего компьютеров и соцсетей. Полученные фотографии были структурированы таким образом, что получилось 26 вопросов, к каждому из которых прилагалось от 2 до 4 фото и 4 варианта ответа. При этом заведомо правильных вариантов ответа не предусматривалось, а ответ на каждый вопрос просто сопровождался комментарием. Хотелось также сделать сохранение истории выбранных вариантов ответа, но в самый последний момент я просто забыл прикрутить эту фичу.
Раскладка фотографий и придумывание вопросов оказались очень трудоемким процессом, и на них ушло полтора вечера.
Далее была реализована база данных, хранящая вопросы. Ниже приведены описание таблиц базы данных и DDL-скрипт.
CLS_QUEST– таблица, содержащая тексты вопросовCLS_QUEST_PHOTO– таблица содержащая относительные пути к фотографиям, которые связаны с задаваемым вопросом; сами фотографии лежат в файловой системе в папках, соответствующих вопросу.CLS_ANSWER– таблица, содержащая варианты ответов на вопрос, а также комментарии к каждому варианту ответа
Скрипт
CREATE SCHEMA IF NOT EXISTS QUE; SET SCHEMA QUE; CREATE TABLE QUE.CLS_QUEST( ID BIGINT IDENTITY, IS_DELETED INT DEFAULT 0, QUEST_TEXT CLOB ); CREATE TABLE QUE.CLS_QUEST_PHOTO( ID BIGINT IDENTITY, ID_QUEST BIGINT NOT NULL, IS_DELETED INT DEFAULT 0, REL_FILE_PATH CLOB, PHOTO_TEXT CLOB, FOREIGN KEY(ID_QUEST) REFERENCES CLS_QUEST(ID) ); CREATE TABLE QUE.CLS_ANSWER( ID BIGINT IDENTITY, ID_QUEST BIGINT NOT NULL, IS_DELETED INT DEFAULT 0, ANSWER_TEXT CLOB, ANSWER_COMMENT CLOB, FOREIGN KEY(ID_QUEST) REFERENCES CLS_QUEST(ID) );
После создания база была наполнена данными вручную, благо в Netbeans, который я использую как среду разработки, достаточно удобный редактор SQL-скриптов.
По истечении двух дней база вопросов и фотографий была готова, времени оставалось совсем немного, пора было переходить к созданию самого бота.
Каркас бота
Напомню, что для создания бота в Telegram необходимо написать @BotFather, пользуясь командой /newbot ввести для бота отображаемое имя и имя пользователя. После выполнения этих действий будет получен токен для доступа к API Telegram. Выглядит это примерно так.

Для красоты добавим фото профиля с помощью /setuserpic.

Теперь перейдем к созданию самого бота с помощью TelegramBots. Напомню, что Telegram позволяет создавать боты работающие с Webhooks и LongPolling-боты. Выбран был второй вариант. Для создания LongPolling-бота, необходимо реализовать собственный класс, наследующий классу
org.telegram.telegrambots.bots.TelegramLongPollingBot. Исходный код класса
public class Bot extends TelegramLongPollingBot { private static final String TOKEN = "TOKEN"; private static final String USERNAME = "USERNAME"; public Bot() { } public Bot(DefaultBotOptions options) { super(options); } @Override public String getBotToken() { return TOKEN; } @Override public String getBotUsername() { return USERNAME; } @Override public void onUpdateReceived(Update update) { if (update.hasMessage() && update.getMessage().hasText()) { processCommand(update); } else if (update.hasCallbackQuery()) { processCallbackQuery(update); } } }
TOKEN – токен для доступа к API Telegram, полученный на этапе регистрации бота.USERNAME – имя бота, полученное на этапе регистрации бота.Метод
onUpdateReceived вызывается при поступлении боту «входящих обновлений». В нашем боте нас интересует обработка текстовых команд (если быть честным, то только команды /start) и обработка колбэков (обратных вызовов), возникающих при нажатии на кнопки инлайн-клавиатуры (размещается в области сообщений). Бот проверяет, является ли входящее обновление текстовым сообщением
update.hasMessage() && update.getMessage().hasText() или колбэком update.hasCallbackQuery(), после чего вызывает соответствующие методы для обработки. О содержимом этих методов поговорим немного позже.Создаваемый бот является обычным консольным приложением и его запуск выглядит следующим образом:
Исходный код main-класса
public class Main { public static void main(String[] args) { ApiContextInitializer.init(); TelegramBotsApi botsApi = new TelegramBotsApi(); Runnable r = () -> { Bot bot = null; HttpHost proxy = AppEnv.getContext().getProxy(); if (proxy == null) { bot = new Bot(); } else { DefaultBotOptions instance = ApiContext .getInstance(DefaultBotOptions.class); RequestConfig rc = RequestConfig.custom() .setProxy(proxy).build(); instance.setRequestConfig(rc); bot = new Bot(instance); } try { botsApi.registerBot(bot); AppEnv.getContext().getMenuManager().setBot(bot); } catch (TelegramApiRequestException ex) { Logger.getLogger(Main.class.getName()) .log(Level.SEVERE, null, ex); } }; new Thread(r).start() while (true) { try { Thread.sleep(80000L); } catch (InterruptedException ex) { Logger.getLogger(Main.class.getName()) .log(Level.SEVERE, null, ex); } } } }
Ничего сложного в инициализации бота нет, однако хочу обратить внимание, что достаточно важно предусмотреть возможность указать боту прокси. В нашем случае настройки прокси хранятся в обычном properties-файле, откуда считываются при старте программы. Также замечу, что в приложении используется собственный нехороший велосипед в виде некоего подобия глобального контекста
AppEnv.getContext(). На момент написания бота исправлять это было некогда, но в новых «поделках» удалось изжить этот велосипед и использовать вместо него Google Guice.Приветственное сообщение
Работа бота естественно начинается с обработки команды /start. Как было написано выше, эта команда обрабатывается методом
processCommand. В начале метода объявим смайлы, которые будут использоваться в тексте приветственного сообщения.
final String smiling_face_with_heart_eyes = new String(Character.toChars(0x1F60D)); final String winking_face = new String(Character.toChars(0x1F609)); final String bouquet = new String(Character.toChars(0x1F490)); final String party_popper = new String(Character.toChars(0x1F389));
Далее производится проверка введенной команды и если это команда /start, то формируется ответное сообщение
answerMessage. У сообщения устанавливается текст setText(), включается поддержка некоторых html-тегов setParseMode("HTML") и устанавливается идентификатор чата, в который сообщение будет отправлено setChatId(update.getMessage().getChatId()). Осталось только добавить кнопку «Начать». Для этого сформируем инлайн-клавиатуру и добавим ее в ответ:SendMessage answerMessage = null; String text = update.getMessage().getText(); if ("/start".equalsIgnoreCase(text)) { answerMessage = new SendMessage(); answerMessage.setText("<b>Привет!" + smiling_face_with_heart_eyes + "\nВо-первых с днем рождения!" + bouquet + bouquet + bouquet + party_popper + " А во-вторых, ты готова поиграть в увлекательную викторину?</b>"); answerMessage.setParseMode("HTML"); answerMessage.setChatId(update.getMessage().getChatId()); InlineKeyboardMarkup markup = keyboard(update); answerMessage.setReplyMarkup(markup); }
Исходный код формирования клавиатуры приведен ниже:
private InlineKeyboardMarkup keyboard(Update update) { final InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); List<List<InlineKeyboardButton>> keyboard = new ArrayList<>(); keyboard.add(Arrays.asList(buttonMain())); markup.setKeyboard(keyboard); return markup; } private InlineKeyboardButton buttonMain() { final String OPEN_MAIN = "OM"; final String winking_face = new String(Character.toChars(0x1F609)); InlineKeyboardButton button = new InlineKeyboardButtonBuilder() .setText("Начать!" + winking_face) .setCallbackData(new ActionBuilder(marshaller) .setName(OPEN_MAIN) .asString()) .build(); return button; }
InlineKeyboardButtonBuilder
public class InlineKeyboardButtonBuilder { private final InlineKeyboardButton button; public InlineKeyboardButtonBuilder(){ this.button = new InlineKeyboardButton(); } public InlineKeyboardButtonBuilder setText(String text){ button.setText(text); return this; } public InlineKeyboardButtonBuilder setCallbackData(String callbackData){ button.setCallbackData(callbackData); return this; } public InlineKeyboardButton build(){ return button; } }
Интересным моментом является установка данных колбэка. Эти данные могут использоваться при обработке нажатия кнопок. В нашем случае в данные колбэка записывается сериализованный в JSON объект. Этот способ тяжеловесен для данной задачи, но позволяет работать с данными возврата без лишних заморочек на преобразование. Данные возврата формируются в специальном билдере
ActionBuilder.Исходный код ActionBuilder
public class Action { protected String name = ""; protected String id = ""; protected String value = ""; public String getName() { return name; } public void setName(String value) { this.name = value; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } } public class ActionBuilder { private final DocumentMarshaller marshaller; private Action action = new Action(); public ActionBuilder(DocumentMarshaller marshaller) { this. marshaller = marshaller; } public ActionBuilder setName(String name) { action.setName(name); return this; } public ActionBuilder setValue(String name) { action.setValue(name); return this; } public String asString() { return marshaller.<Action>marshal(action, "Action"); } public Action build() { return action; } public Action build(Update update) { String data = update.getCallbackQuery().getData(); if (data == null) { return null; } action = marshaller.<Action>unmarshal(data, "Action"); if (action == null) { return null; } return action; } }
Для того, чтобы
ActionBuilder мог вернуть JSON ему необходимо передать маршаллер. Здесь и далее при упоминании переменной marshaller подразумевается, что она является объектом класса, реализующего интерфейс DocumentMarshaller. Исходный код DocumentMarshaller
public interface DocumentMarshaller { <T> String marshal(T document); <T> T unmarshal(String str); <T> T unmarshal(String str, Class clazz); }
Маршаллер, который используется в
ActionBuilder, реализован с использованием Jackson. И, наконец, производится отправка сообщения:
try { if (answerMessage != null) { execute(answerMessage); } } catch (TelegramApiException ex) { Logger.getLogger(Bot.class.getName()) .log(Level.SEVERE, null, ex); }
В итоге, приветственное сообщение выглядит вот так.

Задаем вопросы
Оставалось сделать самое интересное — реализовать логику работы бота.
Для работы с базой вопросов использовалось JPA. Приведу код классов-сущностей.
Исходный код классов-сущностей
public abstract class Classifier implements Serializable { private static final long serialVersionUID = 1L; public Classifier() { } public abstract Long getId(); public abstract Integer getIsDeleted(); public abstract void setIsDeleted(Integer isDeleted); } @Entity @Table(name = "CLS_ANSWER", catalog = "QUEB", schema = "QUE") @XmlRootElement public class ClsAnswer extends Classifier implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Basic(optional = false) @Column(name = "ID") private Long id; @Column(name = "IS_DELETED") private Integer isDeleted; @Lob @Column(name = "ANSWER_TEXT") private String answerText; @Lob @Column(name = "ANSWER_COMMENT") private String answerComment; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idAnswer") private Collection<RegQuestAnswer> regQuestAnswerCollection; @JoinColumn(name = "ID_QUEST", referencedColumnName = "ID") @ManyToOne(optional = false) private ClsQuest idQuest; public ClsAnswer() { } public ClsAnswer(Long id) { this.id = id; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public String getAnswerText() { return answerText; } public void setAnswerText(String answerText) { this.answerText = answerText; } public String getAnswerComment() { return answerComment; } public void setAnswerComment(String answerComment) { this.answerComment = answerComment; } @XmlTransient public Collection<RegQuestAnswer> getRegQuestAnswerCollection() { return regQuestAnswerCollection; } public void setRegQuestAnswerCollection(Collection<RegQuestAnswer> regQuestAnswerCollection) { this.regQuestAnswerCollection = regQuestAnswerCollection; } public ClsQuest getIdQuest() { return idQuest; } public void setIdQuest(ClsQuest idQuest) { this.idQuest = idQuest; } } @Entity @Table(name = "CLS_QUEST", catalog = "QUEB", schema = "QUE") @XmlRootElement public class ClsQuest extends Classifier implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Basic(optional = false) @Column(name = "ID") private Long id; @Column(name = "IS_DELETED") private Integer isDeleted; @Lob @Column(name = "QUEST_TEXT") private String questText; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest") private Collection<RegQuestAnswer> regQuestAnswerCollection; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest") private Collection<ClsAnswer> clsAnswerCollection; @OneToMany(cascade = CascadeType.ALL, mappedBy = "idQuest") private Collection<ClsQuestPhoto> clsQuestPhotoCollection; public ClsQuest() { } public ClsQuest(Long id) { this.id = id; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public String getQuestText() { return questText; } public void setQuestText(String questText) { this.questText = questText; } @XmlTransient public Collection<RegQuestAnswer> getRegQuestAnswerCollection() { return regQuestAnswerCollection; } public void setRegQuestAnswerCollection(Collection<RegQuestAnswer> regQuestAnswerCollection) { this.regQuestAnswerCollection = regQuestAnswerCollection; } @XmlTransient public Collection<ClsAnswer> getClsAnswerCollection() { return clsAnswerCollection; } public void setClsAnswerCollection(Collection<ClsAnswer> clsAnswerCollection) { this.clsAnswerCollection = clsAnswerCollection; } @XmlTransient public Collection<ClsQuestPhoto> getClsQuestPhotoCollection() { return clsQuestPhotoCollection; } public void setClsQuestPhotoCollection(Collection<ClsQuestPhoto> clsQuestPhotoCollection) { this.clsQuestPhotoCollection = clsQuestPhotoCollection; } } @Entity @Table(name = "CLS_QUEST_PHOTO", catalog = "QUEB", schema = "QUE") @XmlRootElement public class ClsQuestPhoto extends Classifier implements Serializable { private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Basic(optional = false) @Column(name = "ID") private Long id; @Column(name = "IS_DELETED") private Integer isDeleted; @Lob @Column(name = "REL_FILE_PATH") private String relFilePath; @Lob @Column(name = "PHOTO_TEXT") private String photoText; @JoinColumn(name = "ID_QUEST", referencedColumnName = "ID") @ManyToOne(optional = false) private ClsQuest idQuest; public ClsQuestPhoto() { } public ClsQuestPhoto(Long id) { this.id = id; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public Integer getIsDeleted() { return isDeleted; } public void setIsDeleted(Integer isDeleted) { this.isDeleted = isDeleted; } public String getRelFilePath() { return relFilePath; } public void setRelFilePath(String relFilePath) { this.relFilePath = relFilePath; } public String getPhotoText() { return photoText; } public void setPhotoText(String photoText) { this.photoText = photoText; } public ClsQuest getIdQuest() { return idQuest; } public void setIdQuest(ClsQuest idQuest) { this.idQuest = idQuest; } }
Также замечу, что здесь и далее для доступа к данным используется объект, реализующий интерфейс
ClassifierRepository, а при упоминании переменной classifierRepository подразумевается, что она является объектом класса, реализующего интерфейс ClassifierRepositoryИсходный код ClassifierRepository
public interface ClassifierRepository { <T extends Classifier> void add(T classifier); <T extends Classifier> List<T> find(Class<T> clazz); <T extends Classifier> T find(Class<T> clazz, Long id); <T extends Classifier> List<T> find(Class<T> clazz, boolean isDeleted); <T extends Classifier> List<T> getAll(Class<T> clazz); <T extends Classifier> List<T> getAll(Class<T> clazz, boolean isDeleted); }
Теперь перейдем к тому моменту, когда нажимается кнопка «Начать!». В этот самый момент бот обрабатывает очередную порцию входящей информации и вызывает ранее упоминавшийся метод
processCallbackQuery(). В начале метода обрабатывается входящее обновление, а также извлекаются данные колбэка. На основании данных колбэка определяется, было ли произведено нажатие на кнопку «Начать!» OPEN_MAIN.equals(action.getName(), либо была нажата кнопка ответа на очередной вопрос. GET_ANSWER.equals(action.getName()).final String OPEN_MAIN = "OM"; final String GET_ANSWER = "GA"; Action action = new ActionBuilder(marshaller).buld(update); String data = update.getCallbackQuery().getData(); Long chatId = update.getCallbackQuery().getMessage().getChatId();
Если викторина только начата, то необходимо проинициализировать список вопросов и задать первый вопрос.
if (OPEN_MAIN.equals(action.getName())) { initQuests(update); sendQuest(update); }
Сейчас рассмотрим инициализацию списка вопросов
initQuests():private void initQuests(Update update) { QuestStateHolder questStateHolder = new QuestStateHolder(); List<ClsQuest> q = classifierRepository.find(ClsQuest.class, false); Collections.shuffle(q); questStateHolder.put(update, new QuestEnumeration(q)); }
В методе
initQuestsсначала получим все 26 вопросов, а потом перемешаем в случайном порядке. После этого вопросы положим в QuestEnumeration, откуда будем получать их по одному, до тех пор, пока не будут получены все 26 вопросов. QuestEnumeration добавим в объект специального класса QuestStateHolder, хранящего соответствие пользователя и его текущей сессии вопросов. Код классов QuestStateHolder и QuestEnumeration ниже.Исходный код QuestStateHolder и QuestEnumeration
public class QuestStateHolder{ private Map<Integer, QuestEnumeration> questStates = new HashMap<>(); public QuestEnumeration get(User user) { return questStates.get(user.getId()) == null ? null : questStates.get(user.getId()); } public QuestEnumeration get(Update update) { User u = getUserFromUpdate(update); return get(u); } public void put(Update update, QuestEnumeration questEnumeration) { User u = getUserFromUpdate(update); put(u, questEnumeration); } public void put(User user, QuestEnumeration questEnumeration) { questStates.put(user.getId(), questEnumeration); } static User getUserFromUpdate(Update update) { return update.getMessage() != null ? update.getMessage().getFrom() : update.getCallbackQuery().getFrom(); } } public class QuestEnumeration implements Enumeration<ClsQuest>{ private List<ClsQuest> quests = new ArrayList<>(); private Integer currentQuest = 0; public QuestEnumeration(List<ClsQuest> quests){ this.quests.addAll(quests); } @Override public boolean hasMoreElements() { return currentQuest < quests.size(); } @Override public ClsQuest nextElement() { ClsQuest q = null; if (hasMoreElements()){ q = quests.get(currentQuest); currentQuest++; } return q; } public Integer getCurrentQuest(){ return currentQuest; } }
После инициализации задается первый вопрос. Но об этом поговорим чуть позже. А пока рассмотрим ситуацию, когда пришел ответ на уже заданный вопрос и боту необходимо отправить комментарий, относящийся к этому варианту ответа. Здесь все достаточно просто, сначала ищем ответ в базе данных (уникальный идентификатор варианта ответа сохранен в
CallbackData кнопки, на которую было произведено нажатие):Long answId = Long.parseLong(action.getValue()); ClsAnswer answ = classifierRepository.find(ClsAnswer.class, answId);
Потом готовим на основе найденного ответа сообщение и отправляем его:
SendMessage comment = new SendMessage(); comment.setParseMode("HTML"); comment.setText("<b>Твой ответ:</b> " + answ.getAnswerText() + "\n<b>Комментарий к ответу:</b> " + answ.getAnswerComment() + "\n"); comment.setChatId(chatId); execute(comment);
Теперь рассмотрим метод
sendQuest, который отправляет очередной вопрос. Начинается все с получения очередного вопроса:QuestEnumeration qe = questStateHolder.get(update); ClsQuest nextQuest = qe.nextElement();
Если Enumeration еще содержит элементы, то готовим вопрос к отправке, в противном случае пора выводить сообщение об окончании викторины. Отправляем сам вопрос:
Long chatId = update.getCallbackQuery().getMessage().getChatId(); SendMessage quest = new SendMessage(); quest.setParseMode("HTML"); quest.setText("<b>Вопрос " + qe.getCurrentQuest() + ":</b> " + nextQuest.getQuestText()); quest.setChatId(chatId); execute(quest);
Теперь отправляем фотографии, относящиеся к данному вопросу:
for (ClsQuestPhoto clsQuestPhoto : nextQuest.getClsQuestPhotoCollection()) { SendPhoto sendPhoto = new SendPhoto(); sendPhoto.setChatId(chatId); sendPhoto.setNewPhoto(new File("\\photo" + clsQuestPhoto.getRelFilePath())); sendPhoto(sendPhoto); }
И, наконец, варианты ответа:
SendMessage answers = new SendMessage(); answers.setParseMode("HTML"); answers.setText("<b>Варианты ответа:</b>"); answers.setChatId(chatId); answers.setReplyMarkup(keyboardAnswer(update, nextQuest)); execute(answers);
Клавиатура с вариантами ответа формируется следующим образом
Исходный код
private InlineKeyboardMarkup keyboardAnswer(Update update, ClsQuest quest) { final InlineKeyboardMarkup markup = new InlineKeyboardMarkup(); List<List<InlineKeyboardButton>> keyboard = new ArrayList<>(); for (ClsAnswer clsAnswer : quest.getClsAnswerCollection()) { keyboard.add(Arrays.asList(buttonAnswer(clsAnswer))); } markup.setKeyboard(keyboard); return markup; } private InlineKeyboardButton buttonAnswer(ClsAnswer clsAnswer) { InlineKeyboardButton button = new InlineKeyboardButtonBuilder() .setText(clsAnswer.getAnswerText()) .setCallbackData(new ActionBuilder(marshaller) .setName(GET_ANSWER) .setValue(clsAnswer.getId().toString()) .asString()) .build(); return button; }
Вопрос отправлен. Выглядеть это будет примерно так (фотографии немного размыты, чтобы никого не смущать).

В тот момент, когда вопросы закончились, будет сформировано сообщение об окончании викторины:
SendMessage answers = new SendMessage(); answers.setParseMode("HTML"); answers.setText("<b>Ну вот и все! Подробности на процедуре награждения</b> \n " + "Если хочешь начать заново нажми кнопку 'Начать' или введи /start"); answers.setChatId(chatId); execute(answers);
И в самом конце отправим забавный стикер:
SendSticker sticker = new SendSticker(); sticker.setChatId(chatId); File stikerFile = new File("\\photo\\stiker.png"); sticker.setNewSticker(stikerFile); sendSticker(sticker);

На этом работа бота завершается.
Работа над всей описанной функциональностью была завершена где-то в 11 часов вечера дня, предшествующего дню X. Замечу, что имея в виду некоторые особенности, я понимал, что запустить бота надо ровно в 12 ночи. В связи с этим я испытывал некоторый цейтнот (почему и забыл про историю ответов). Кроме того коллегу необходимо было как-то оповестить об этой викторине. По ряду причин просто скинуть ссылку я не мог, поэтому доверил оповещение другому боту (благо в процессе тестирования идентификатор пользователя был сохранен и бот мог свободно писать). На этом история написания бота заканчивается.
Заключение
Каких-то глубоких выводов из этой истории не будет. Сам бот был принят с интересом и понравился, так что старался я не зря. Если переходить к технической части вопроса, то работа над ботом позволила задуматься над тем, что от некоторых костылей и велосипедов пора избавляться, что я и попытался сделать в следующем боте.
Также хочу заметить, что сама идея ботов очень перспективна. Есть множество повседневных задач от заказа пиццы до вызова такси, которые предлагается решать посредством мобильных приложений, сайтов и их мобильных версий или телефонных звонков оператору. С одной стороны все эти способы доказали свою эффективность, удобны и в ближайшее время вряд ли изменятся. С другой стороны приложения для мобильных устройств хотя и богаты функциональностью, но требуют установки, обновления и изучения их интерфейса, а еще имеют свойство кушать батарею. Сайты и их мобильные версии требуют от пользователя как минимум перехода в браузер и работы с новым интерфейсом, что не всегда удобно, особенно на мобильных устройствах. Взаимодействие по телефону удобно для многих, но не подразумевает какой-либо визуализации в принципе, а кроме того оператор всегда будет узким местом системы. При решении тех же задач боты не требуют установки чего-либо кроме мессендежера, не требуют изучения новых интерфейсов и позволяют пользователю работать асинхронно (в отличие от телефонного звонка) в относительно знакомом интерфейсе мессенджера. Мессенджер в таком случае предоставляет некую среду для клиент-серверного взаимодействия, где в роли сервера выступает бот, а клиентская часть реализована средствами мессенджера. Безусловно, при работе с ботами для пользователя также возникают различные трудности, в чем-то схожие с трудностями работы с текстовыми интерфейсами, в чем-то обусловленные ограничениями самих мессенджеров. Но все равно боты, ориентированные на решение небольших повседневных задач (или на развлечения, как бот, описанный в этой статье) представляются перспективными.
Этой мой первый пост, буду признателен за конструктивную обратную связь!
UPD: Залил проект на GitHub github.com/altmf/questbot
