
@Override
public void onUpdateReceived(Update update) {
UpdatesReceiver.handleUpdates(update);
}
который я передаю обработчику (собственный класс UpdatesReceiver):
public static void handleUpdates(Update update) {
...
if (update.hasMessage() && update.getMessage().hasText()){
log.info("[Update (id {}) типа \"Текстовое сообщение\"]", update.getUpdateId());
new TextMessageHandler(update, replyGenerator).handleTextMessage();
}
else if (update.hasCallbackQuery()) {
//логгирование
new CallbackQueryHandler(update, replyGenerator).handleCallBackQuery();
}
else {
//логгирование
replyGenerator.sendTextMessage(update.getMessage().getChatId(), "Я могу принимать только текстовые сообщения!");
}
}
UpdatesReceiver - это центральный обработчик, который в зависимости от типа апдейта передает управление в другой специализированный обработчик: TextMessageHandler или CallbackQueryHandler, в конструкторы которым я дальше по цепочке передаю update.
Update - это самое важное при работе с ботом и его нельзя терять, так как с помощью информации, хранящейся в апдейте, мы узнаем какому юзеру и в какой чат необходимо отсылать ответ. Для генерирования ответов пользователю написал отдельный класс. Он может отправлять обычное текстовое сообщение, сообщение с inline клавиатурой, сообщение с картинкой и сообщение с reply клавиатурой.
Inline-клавиатура выглядит так:

public void handleCallBackQuery() {
String call_data = update.getCallbackQuery().getData();
long message_id = update.getCallbackQuery().getMessage().getMessageId();
long chat_id = update.getCallbackQuery().getMessage().getChatId();
switch (call_date){
case "/price" :
//тут что-то сделать
break;
...
Reply-клавиатура выглядит так:

public synchronized void sendInlineKeyboardMessage(long chat_id, String gameTitle) {
SendMessage keyboard = InlineKeyboardMarkupBuilder.create(chat_id)
.setText("Вы может узнать следующую информацию об игре " + gameTitle)
.row()
.button("Стоимость " + "\uD83D\uDCB0", "/price " + gameTitle)
.button("Обновлено " + "\uD83D\uDDD3", "/updated " + gameTitle)
.button("Версия " + "\uD83D\uDEE0", "/version " + gameTitle)
.endRow()
.row()
.button("Требования " + "\uD83D\uDCF5", "/requirements " + gameTitle)
.button("Покупки " + "\uD83D\uDED2", "/iap " + gameTitle)
.button("Размер " + "\uD83D\uDD0E", "/size " + gameTitle)
.endRow()
.row()
.button("Получить всю информацию об игре" + "\uD83D\uDD79", "/all " + gameTitle)
.endRow()
.row()
.button("Скрыть клавиатуру", "close")
.endRow()
.build();
try {
execute(keyboard);
} catch (TelegramApiException e) {
log.error("[Не удалось отправить сообщение с -inline- клавиатурой]: {}", e.getMessage());
}
}
Для придания боту строгого функционала, были придуманы специальные команды через символ слэша: /library, /help, /game и т.д. Иначе пришлось бы обрабатывать всякий мусор, который мог написать пользователь. Собственно, для этого был написан MessageHandler:
if (message.equals(ChatCommands.START.getDescription())) {
replyGenerator.sendTextMessage(chat_id, new StartMessageHandler().reply());
replyGenerator.sendReplyKeyboardMessage(chat_id);
}
else if (message.equals(ChatCommands.HELP.getDescription())
|| message.equalsIgnoreCase("Помощь")) {
replyGenerator.sendTextMessage(chat_id, new HelpMessageHandler().reply());
}
...
Таким образом, в зависимости от того, какую команду вы отправите боту, в работу будет включаться специальный обработчик.
Идем далее и рассмотрим работу парсера и библиотеки. Если отправить боту ссылку на игру в магазине Google Play, то автоматически сработает специальный handler. В ответ пользователь получит информацию об игре в следующем виде:

static {
TypeFactory typeFactory = mapper.getTypeFactory();
MapType mapType = typeFactory.constructMapType(ConcurrentSkipListMap.class, String.class, GooglePlayGame.class);
try {
Path path = Paths.get(LIBRARY_PATH);
if (!Files.exists(path)) {
Files.createDirectories(path.getParent());
Files.createFile(path);
log.info("[Файл библиотеки создан]");
}
else {
ConcurrentMap<string, googleplaygame=""> temporary = mapper.readValue(new File(LIBRARY_PATH), mapType);
games.putAll(temporary);
log.info("[Количество игр в загруженной библиотеке] = " + games.size());
}
}
catch (IOException e) {
log.error("[Ошибка при чтении/записи файла] {}", e.getMessage());
}
}
Тут дополнительно приходится читать данные из файла во временную мапу, которую потом "копировать" в полноценную, дабы сохранить нечувствительность к регистру при поиске игры в файле (написав tITan QuEST, бот все равно найдет игру Titan Quest в библиотеке). Другого решения найти не удалось, таковы особенности десериализации с использованием Jackson.
Итак, при каждом запросе по ссылке игра по возможности добавляется в библиотеку, и библиотека тем самым расширяется. Далее информацию о конкретной игре можно достать по команде /library Название_Игры. Можно узнать как определенный параметр (например, текущую версию), так и все параметры сразу. Это реализовано с помощью inline-клавиатуры, которая была рассмотрена ранее.
В ходе работы применял и навыки, полученные здесь в ходе решения задач. Например, список названий случайных игр, находящихся в библиотеке (опция доступна по команде /library):
private String getRandomTitles(){
if (LibraryService.getLibrary().size() < 10){
return String.join("\n", LibraryService.getLibrary().keySet());
}
List<string> keys = new ArrayList<>(LibraryService.getLibrary().keySet());
Collections.shuffle(keys);
List<string> randomKeys = keys.subList(0, 10);
return String.join("\n", randomKeys);
}
Как бот обрабатывает ссылки? Он проверяет их на принадлежность к Google Play (хост, протокол, порт):
private static class GooglePlayCorrectURL {
private static final String VALID_HOST = "play.google.com";
private static final String VALID_PROTOCOL = "https";
private static final int VALID_PORT = -1;
private static boolean isLinkValid(URI link) {
return (isHostExist(link) && isProtocolExist(link) && link.getPort() == VALID_PORT);
}
private static boolean isProtocolExist(URI link) {
if (link.getScheme() != null) {
return link.getScheme().equals(VALID_PROTOCOL);
}
else {
return false;
}
}
private static boolean isHostExist(URI link) {
if (link.getHost() != null) {
return link.getHost().equals(VALID_HOST);
}
else {
return false;
}
}
Если всё в порядке, то бот подключается по ссылке с помощью библиотеки Jsoup, которая позволяет достать HTML-код странички, подлежащий дальнейшему анализу и парсингу. Обмануть бота неправильной или вредной ссылкой не получится.
if (GooglePlayCorrectURL.isLinkValid(link)){
if (!link.getPath().contains("apps")){
throw new InvalidGooglePlayLinkException("К сожалению, бот работает исключительно с играми. Введите другую ссылку.");
}
URL = forceToRusLocalization(URL);
document = Jsoup.connect(URL).get();
}
else {
throw new NotGooglePlayLinkException();
}
...
Здесь пришлось решать проблему с региональными настройками. Бот подключается к магазину Google Play из сервера, который находится в Европе, поэтому и страничка в магазине Google Play открывается на соответствующем языке. Пришлось писать костыль, который принудительно осуществляет "редирект" на российскую версию странички (проект все-таки нацелен был на нашу аудиторию). Для этого в конце ссылки нужно аккуратно дописать в GET запросе к серверу Google Play параметр hl: &hl=ru.
private String forceToRusLocalization(String URL) {
if (URL.endsWith("&hl=ru")){
return URL;
}
else {
if (URL.contains("&hl=")){
URL = URL.replace(
URL.substring(URL.length()-"&hl=ru".length()), "&hl=ru");
}
else {
URL += "&hl=ru";
}
}
return URL;
}
После удачного подключения мы получаем HTML-документ, готовый для анализа и парсинга, но это уже выходит за рамки этой статьи. Код парсера тут. Собственно парсер достает нужную информацию и создает объект с игрой, который в дальнейшем в случае необходимости добавляется в библиотеку.
<h2>Подытожу</h2>Бот поддерживает несколько команд, в которых заложена определенная функциональность. Он получает сообщение от пользователя и сопоставляет их со своими командами. Если это ссылка или команда /game + ссылка, он проверяет эту ссылку на принадлежность к Google Play. Если ссылка корректная, он осуществляет подключение посредством Jsoup и получает HTML-документ. Данный документ анализируется на основе написанного парсера. Из документа вытаскивается нужная информация об игре, и далее объект с игрой заполняется этими данными. Далее объект с игрой помещается в локальное хранилище (если игры там еще нет) и тут же записывается в файл для избежания потерь данных. Записанную в библиотеку игру (название игры - ключ для мапы, объект с игрой - значение для мапы), можно получить по команде /library Название_игры. Если указанная игра будет найдена в библиотеке бота, то пользователю вернется inline-клавиатура, с помощью которой он может получить информацию об игре. Если игры найдено не будет, необходимо либо убедиться в правильности написания названия (оно должно целиком соответствовать названию игры в магазине Google Play за исключением регистра), либо добавить игру в библиотеку, отправив боту ссылку на игру.
Деплой бота я осуществлял на heroku и для тех, кто в будущем планирует написать своего бота и разместить его бесплатно на heroku, дам парочку рекомендаций для решения трудностей, с которыми вы можете столкнуться (т.к. с ними я столкнулся сам). К сожалению, из-за особенностей heroku, библиотека бота постоянно "обнуляется" раз в 24 часа. Мой тариф не поддерживает хранение файлов на серверах heroku, поэтому он просто подтягивает мой файл с играми с гитхаба. Решений было несколько: использовать БД, либо искать другой сервер, который хранил бы этот файл с игрой. Я пока решил ничего не предпринимать, так как по сути бот не такой уж и полезный. Он мне был необходим скорее для получения полноценного опыта, чего я в принципе и добился.
Итак, рекомендации по heroku:
Регистрироваться на heroku скорее всего придется с помощью VPN, если вы живете в России.
В корень проекта необходимо положить файл без расширения под названием Procfile. Его содержимое должно быть таким: https://github.com/miroha/Telegram-Bot/blob/master/Procfile
В pom.xml добавить следующие строчки по образцу, где в теге mainClass указать путь к классу, который содержит main метод: bot.BotApplication (если класс BotApplication лежит в папке bot).
Не осуществлять каких-то сборок проекта с помощью команд mvn package и т.д., heroku все соберет за вас сам.
Желательно добавить в проект gitignore, например такой:
# Log file *.log # Compiled resources target # Tests test # IDEA files .idea *.iml
Собственно загрузить проект на github, а далее подключить репозиторий в heroku (либо используйте другие способы, там их 3, если не ошибаюсь).
Если загрузка прошла успешно ("Build succeeded"), обязательно зайдите в Configure Dynos:
и переключите ползунок, а затем убедитесь, что он в положении ON (из-за того, что я это не сделал, мой бот не работал и я пару дней ломал голову и сделал очень много лишних телодвижений).
Прячьте токен бота на гитхабе. Для этого необходимо получать токен из переменной окружения:
public class Bot extends TelegramLongPollingBot { private static final String BOT_TOKEN = System.getenv("TOKEN"); @Override public String getBotToken() { return BOT_TOKEN; } ... }
А затем после деплоя бота, задать эту переменную в dashboard'e heroku во вкладке Settings (справа от TOKEN будет поле VALUE, туда и копируйте токен вашего бота):
- получил полностью рабочий проект, написанный на Java;
- научился работать со сторонним API (Telegram Bot API);
- на практике углубился в сериализацию, много поработал с JSON и библиотекой Jackson (изначально использовал GSON, но с ней были проблемы);
- укрепил свои навыки при работе с файлами, познакомился с Java NIO;
- научился работать с конфигурационными .xml файлами и приучил себя к логгированию;
- улучшил владение средой разработки (IDEA);
- научился работать с git и познал ценность gitignore;
- получил навыки в парсинге веб-страничек (библиотека Jsoup);
- изучил и использовал несколько паттернов проектирования;
- развил в себе чувство и желание улучшать код (рефакторинг);
- научился находить решения в сети и не стесняться задавать вопросы, на которые не удалось найти ответа.

ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ